[GH-ISSUE #3346] Build Fails Due to Missing username in SSR Session Validation #9575

Closed
opened 2026-04-13 05:06:00 -05:00 by GiteaMirror · 6 comments
Owner

Originally created by @marienjus on GitHub (Jul 11, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/3346

BetterAuth breaks Next.js static builds due to eager evaluation of schema/auth internals.

I dont know why, but when I comment out anything related to auth in server components, build works..
version: "better-auth": "^1.2.12"

   Creating an optimized production build ...
BUILD: Server-side rendering phase
BUILD: Server-side rendering phase
 ✓ Compiled successfully
   Skipping validation of types
   Skipping linting
   Collecting page data  .[INFO] Redis connection established {}
   Collecting page data  ...{ username: [ "Username can't be blank" ] }

> Build error occurred
Error: Failed to collect page data for /dashboard/[organizationId]/pos/shifts
    at /home/qodestackrd/Desktop/PROJECTS/alcoraos/node_modules/.pnpm/next@14.1.3_@opentelemetry+api@1.
9.0_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/next/dist/build/utils.js:1258:15
    at process.processTicksAndRejections (node:internal/process/task_queues:105:5) {
  type: 'Error'
}
   Collecting page data  . ELIFECYCLE  Command failed with exit code 1.
command finished with error: command (/home/qodestackrd/Desktop/PROJECTS/alcoraos/apps/web) /home/qodes
tackrd/.local/share/pnpm/.tools/pnpm/10.4.1/bin/pnpm run build exited (1)
└─ web#build ──
web#build: command (/home/qodestackrd/Desktop/PROJECTS/alcoraos/apps/web) /home/qodestackrd/.local/share/pnpm/.tools/pnpm/10.4.1/bin/pnpm run build exited (1)

 Tasks:    8 successful, 9 total
Cached:    8 cached, 9 total
  Time:    56.314s 
Failed:    web#build

pos/shifts basically has this:

import { ShiftDashboard } from "@/components/shifts/shift-dashboard"
import { auth } from "@workspace/auth"
import { redirect } from "next/navigation"
import { headers } from "next/headers"

export default async function ShiftsPage() {
    const data = await auth.api.getSession({
        headers: await headers(),
    })

    if (!data?.user) {
        redirect("/sign-in")
    }

    if (!data.session.activeOrganizationId) {
        return (
            <div className="flex items-center justify-center min-h-screen">
                <div className="text-center">
                    <h2 className="text-xl font-semibold mb-2">No Active Organization</h2>
                    <p className="text-gray-600">Please select an organization to manage shifts</p>
                </div>
            </div>
        )
    }

    return <ShiftDashboard organizationId={data.session.activeOrganizationId} />
}
Originally created by @marienjus on GitHub (Jul 11, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/3346 BetterAuth breaks Next.js static builds due to eager evaluation of schema/auth internals. I dont know why, but when I comment out anything related to auth in server components, build works.. version: "better-auth": "^1.2.12" ```bash Creating an optimized production build ... BUILD: Server-side rendering phase BUILD: Server-side rendering phase ✓ Compiled successfully Skipping validation of types Skipping linting Collecting page data .[INFO] Redis connection established {} Collecting page data ...{ username: [ "Username can't be blank" ] } > Build error occurred Error: Failed to collect page data for /dashboard/[organizationId]/pos/shifts at /home/qodestackrd/Desktop/PROJECTS/alcoraos/node_modules/.pnpm/next@14.1.3_@opentelemetry+api@1. 9.0_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/next/dist/build/utils.js:1258:15 at process.processTicksAndRejections (node:internal/process/task_queues:105:5) { type: 'Error' } Collecting page data . ELIFECYCLE  Command failed with exit code 1. command finished with error: command (/home/qodestackrd/Desktop/PROJECTS/alcoraos/apps/web) /home/qodes tackrd/.local/share/pnpm/.tools/pnpm/10.4.1/bin/pnpm run build exited (1) └─ web#build ── web#build: command (/home/qodestackrd/Desktop/PROJECTS/alcoraos/apps/web) /home/qodestackrd/.local/share/pnpm/.tools/pnpm/10.4.1/bin/pnpm run build exited (1) Tasks: 8 successful, 9 total Cached: 8 cached, 9 total Time: 56.314s Failed: web#build ``` pos/shifts basically has this: ```jsx import { ShiftDashboard } from "@/components/shifts/shift-dashboard" import { auth } from "@workspace/auth" import { redirect } from "next/navigation" import { headers } from "next/headers" export default async function ShiftsPage() { const data = await auth.api.getSession({ headers: await headers(), }) if (!data?.user) { redirect("/sign-in") } if (!data.session.activeOrganizationId) { return ( <div className="flex items-center justify-center min-h-screen"> <div className="text-center"> <h2 className="text-xl font-semibold mb-2">No Active Organization</h2> <p className="text-gray-600">Please select an organization to manage shifts</p> </div> </div> ) } return <ShiftDashboard organizationId={data.session.activeOrganizationId} /> } ```
GiteaMirror added the locked label 2026-04-13 05:06:00 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Jul 11, 2025):

Hi @marienjus! I'm Dosu and I’m helping the better-auth team.

This happens because BetterAuth's schema validation (especially with the username plugin) runs unconditionally—even during static builds or SSR—so if your server component references auth or calls getSession, it expects a valid session/user and a non-blank username. During static builds, there is no user context, so validation fails and you get errors like { username: [ "Username can't be blank" ] } [plugin code].

Commenting out auth code lets the build succeed because it avoids triggering this eager validation.

This is a known pain point with BetterAuth and SSR/static builds in frameworks like Next.js [issue]. There are no built-in guards in the username plugin to skip validation during build time [plugin code].

Workarounds:

  • Only call auth.api.getSession at runtime, not during static build. For static pages, avoid importing or calling auth logic in server components.
  • If you need SSR session data, use a Provider pattern to inject initial session data at the app root, so you don't fetch or validate session data during build [discussion].
  • Consider lazy-loading auth logic or wrapping it in a runtime check (e.g., if (typeof window !== "undefined" || process.env.NEXT_PHASE === "phase-production-server")) to avoid running it during static build.
  • If possible, update BetterAuth and related packages—recent releases have improved SSR/static build handling [issue].

If you need a code example or want to discuss a specific workaround for your setup, let me know!

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Join Discord Share on X

<!-- gh-comment-id:3062124688 --> @dosubot[bot] commented on GitHub (Jul 11, 2025): <!-- Greeting --> Hi @marienjus! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This happens because BetterAuth's schema validation (especially with the username plugin) runs unconditionally—even during static builds or SSR—so if your server component references `auth` or calls `getSession`, it expects a valid session/user and a non-blank username. During static builds, there is no user context, so validation fails and you get errors like `{ username: [ "Username can't be blank" ] }` [[plugin code](https://github.com/better-auth/better-auth/blob/8ac324f64da1a63a48ea407b3aadee5816ccddf4/packages/better-auth/src/plugins/username/index.ts)]. Commenting out auth code lets the build succeed because it avoids triggering this eager validation. This is a known pain point with BetterAuth and SSR/static builds in frameworks like Next.js [[issue](https://github.com/better-auth/better-auth/issues/2768)]. There are no built-in guards in the username plugin to skip validation during build time [[plugin code](https://github.com/better-auth/better-auth/blob/8ac324f64da1a63a48ea407b3aadee5816ccddf4/packages/better-auth/src/plugins/username/index.ts)]. **Workarounds:** - Only call `auth.api.getSession` at runtime, not during static build. For static pages, avoid importing or calling auth logic in server components. - If you need SSR session data, use a Provider pattern to inject initial session data at the app root, so you don't fetch or validate session data during build [[discussion](https://github.com/better-auth/better-auth/issues/2768)]. - Consider lazy-loading auth logic or wrapping it in a runtime check (e.g., `if (typeof window !== "undefined" || process.env.NEXT_PHASE === "phase-production-server")`) to avoid running it during static build. - If possible, update BetterAuth and related packages—recent releases have improved SSR/static build handling [[issue](https://github.com/better-auth/better-auth/issues/3162)]. If you need a code example or want to discuss a specific workaround for your setup, let me know! <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/2f5050df-6c96-4e1f-85e7-deda299e30bb?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/2f5050df-6c96-4e1f-85e7-deda299e30bb?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/2f5050df-6c96-4e1f-85e7-deda299e30bb?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/2f5050df-6c96-4e1f-85e7-deda299e30bb?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/2f5050df-6c96-4e1f-85e7-deda299e30bb?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/2f5050df-6c96-4e1f-85e7-deda299e30bb?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/2f5050df-6c96-4e1f-85e7-deda299e30bb?feedback_type=other)</sup>&nbsp;&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/3346)
Author
Owner

@marienjus commented on GitHub (Jul 11, 2025):

@Bekacru see:

   Creating an optimized production build ...
BUILD: Server-side rendering phase
BUILD: Server-side rendering phase
 ✓ Compiled successfully
   Skipping validation of types
   Skipping linting
   Collecting page data  .[INFO] Redis connection established {}
[INFO] Redis connection established {}
{ username: [ "Username can't be blank" ] }

> Build error occurred
Error: Failed to collect page data for /dashboard/[organizationId]/profile
    at /home/qodestackrd/Desktop/PROJECTS/alcoraos/node_modules/.pnpm/next@14.1.3_@opentelemetry+api@1.
9.0_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/next/dist/build/utils.js:1258:15
    at process.processTicksAndRejections (node:internal/process/task_queues:105:5) {
  type: 'Error'
}
   Collecting page data  . ELIFECYCLE  Command failed with exit code 1.

Immediately I migrated pos/shifts page like :

import { ShiftDashboard } from "@/components/shifts/shift-dashboard"

export default async function ShiftsPage() {
    // if (!data.session.activeOrganizationId) {
    //     return (
    //         <div className="flex items-center justify-center min-h-screen">
    //             <div className="text-center">
    //                 <h2 className="text-xl font-semibold mb-2">No Active Organization</h2>
    //                 <p className="text-gray-600">Please select an organization to manage shifts</p>
    //             </div>
    //         </div>
    //     )
    // }

    return <ShiftDashboard />
}

It comes back and finds the next page with auth, in this case its profile page:

import { auth } from "@workspace/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import AccountSwitcher from "@/components/auth/account-switch";

import { Tabs, TabsContent, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
import { User, Building2, ShieldCheck, BadgeDollarSign } from "lucide-react";
import { UserManagement } from "@/components/auth/user-management";
import UserCard from "@/components/auth/user-card";
import { OrganizationCard } from "@/components/auth/organization-card";
import { CurrentSubscription } from "@/components/subscriptions/current-subscription"

export default async function DashboardProfile() {
    const [session, activeSessions, deviceSessions, organization,/**subscriptions*/] =
        await Promise.all([
            auth.api.getSession({
                headers: await headers(),
            }),
            auth.api.listSessions({
                headers: await headers(),
            }),
            auth.api.listDeviceSessions({
                headers: await headers(),
            }),
            auth.api.getFullOrganization({
                headers: await headers(),
            }),
            // auth.api.listActiveSubscriptions({
            // 	headers: await headers(),
            // }),
        ]).catch((e) => {
            console.log("ERROR IN PROFILE PAGE", e);
            throw redirect("/sign-in");
        });

    if (!session) {

    }

    return (
        <div className="max-w-4xl mx-auto mt-2">
            <Tabs defaultValue="account" className="w-full">
                <TabsList className="grid w-full grid-cols-3 mb-3">
                    <TabsTrigger value="account" className="flex items-center gap-2">
                        <User className="h-4 w-4" />
                        <span>Account Settings</span>
                    </TabsTrigger>
                    <TabsTrigger value="business" className="flex items-center gap-2">
                        <Building2 className="h-4 w-4" />
                        <span>Business Units</span>
                    </TabsTrigger>
                    <TabsTrigger value="billing" className="flex items-center gap-2">
                        <BadgeDollarSign className="h-4 w-4" />
                        <span>Billing</span>
                    </TabsTrigger>
                </TabsList>

                <TabsContent value="account" className="space-y-2">
                    <div className="grid gap-2">
                        <div className="flex items-center justify-between">
                            <h2 className="text-sm font-light flex items-center gap-1">
                                <User className="h-5 w-5 text-primary" />
                                Personal Account
                            </h2>
                            <div className="bg-primary/10 px-3 py-1 rounded-full text-xs font-medium text-primary flex items-center gap-1">
                                <ShieldCheck className="h-3.5 w-3.5" />
                                Account Settings
                            </div>
                        </div>

                        <AccountSwitcher
                            sessions={JSON.parse(JSON.stringify(deviceSessions))}
                        />

                        <div className="grid gap-4">
                            <div className="space-y-3">
                                <div className="flex items-center gap-2">
                                    <User className="h-4 w-4 text-primary" />
                                    <h3 className="text-sm font-medium">User Profile</h3>
                                </div>
                                <UserCard
                                    session={JSON.parse(JSON.stringify(session))}
                                    activeSessions={JSON.parse(JSON.stringify(activeSessions))}
                                // subscription={subscriptions.find(
                                // 	(sub) => sub.status === "active" || sub.status === "trialing",
                                // )}
                                />
                            </div>
                        </div>
                    </div>
                </TabsContent>

                <TabsContent value="business" className="space-y-4">
                    <div className="grid gap-4">
                        <div className="flex items-center justify-between">
                            <h2 className="text-xl font-normal flex items-center gap-2">
                                <Building2 className="h-5 w-5 text-teal-600" />
                                Organization Management
                            </h2>
                            <div className="bg-primary/10 px-3 py-1 rounded-full text-xs font-medium text-primary flex items-center gap-1">
                                <Building2 className="h-3.5 w-3.5" />
                                Business Settings
                            </div>
                        </div>

                        <OrganizationCard
                            session={JSON.parse(JSON.stringify(session))}
                            activeOrganization={JSON.parse(JSON.stringify(organization))}
                        />
                    </div>
                </TabsContent>

                <TabsContent value="billing" className="space-y-4">
                    <div className="max-w-4xl mx-auto py-6">
                        <div className="mb-6">
                            <h1 className="text-2xl font-normal tracking-tight">Subscription & Billing</h1>
                            <p className="mt-2 text-muted-foreground">Manage your subscription and billing information</p>
                        </div>

                        <CurrentSubscription />
                    </div>
                </TabsContent>
            </Tabs>
        </div>
    );
}

What is it that am doing wrong? I dont even expect something like username in better auth. This seems to originate from internal schema validation, possibly using Zod, during the build-time rendering of pages that use getSession() or getSafeSession().
I am using turborepo and next v14.*(tried downgrading but to no avail) :(

<!-- gh-comment-id:3062136855 --> @marienjus commented on GitHub (Jul 11, 2025): @Bekacru see: ```bash Creating an optimized production build ... BUILD: Server-side rendering phase BUILD: Server-side rendering phase ✓ Compiled successfully Skipping validation of types Skipping linting Collecting page data .[INFO] Redis connection established {} [INFO] Redis connection established {} { username: [ "Username can't be blank" ] } > Build error occurred Error: Failed to collect page data for /dashboard/[organizationId]/profile at /home/qodestackrd/Desktop/PROJECTS/alcoraos/node_modules/.pnpm/next@14.1.3_@opentelemetry+api@1. 9.0_react-dom@19.1.0_react@19.1.0__react@19.1.0/node_modules/next/dist/build/utils.js:1258:15 at process.processTicksAndRejections (node:internal/process/task_queues:105:5) { type: 'Error' } Collecting page data . ELIFECYCLE  Command failed with exit code 1. ``` Immediately I migrated pos/shifts page like : ```jsx import { ShiftDashboard } from "@/components/shifts/shift-dashboard" export default async function ShiftsPage() { // if (!data.session.activeOrganizationId) { // return ( // <div className="flex items-center justify-center min-h-screen"> // <div className="text-center"> // <h2 className="text-xl font-semibold mb-2">No Active Organization</h2> // <p className="text-gray-600">Please select an organization to manage shifts</p> // </div> // </div> // ) // } return <ShiftDashboard /> } ``` It comes back and finds the next page with auth, in this case its profile page: ```jsx import { auth } from "@workspace/auth"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; import AccountSwitcher from "@/components/auth/account-switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@workspace/ui/components/tabs"; import { User, Building2, ShieldCheck, BadgeDollarSign } from "lucide-react"; import { UserManagement } from "@/components/auth/user-management"; import UserCard from "@/components/auth/user-card"; import { OrganizationCard } from "@/components/auth/organization-card"; import { CurrentSubscription } from "@/components/subscriptions/current-subscription" export default async function DashboardProfile() { const [session, activeSessions, deviceSessions, organization,/**subscriptions*/] = await Promise.all([ auth.api.getSession({ headers: await headers(), }), auth.api.listSessions({ headers: await headers(), }), auth.api.listDeviceSessions({ headers: await headers(), }), auth.api.getFullOrganization({ headers: await headers(), }), // auth.api.listActiveSubscriptions({ // headers: await headers(), // }), ]).catch((e) => { console.log("ERROR IN PROFILE PAGE", e); throw redirect("/sign-in"); }); if (!session) { } return ( <div className="max-w-4xl mx-auto mt-2"> <Tabs defaultValue="account" className="w-full"> <TabsList className="grid w-full grid-cols-3 mb-3"> <TabsTrigger value="account" className="flex items-center gap-2"> <User className="h-4 w-4" /> <span>Account Settings</span> </TabsTrigger> <TabsTrigger value="business" className="flex items-center gap-2"> <Building2 className="h-4 w-4" /> <span>Business Units</span> </TabsTrigger> <TabsTrigger value="billing" className="flex items-center gap-2"> <BadgeDollarSign className="h-4 w-4" /> <span>Billing</span> </TabsTrigger> </TabsList> <TabsContent value="account" className="space-y-2"> <div className="grid gap-2"> <div className="flex items-center justify-between"> <h2 className="text-sm font-light flex items-center gap-1"> <User className="h-5 w-5 text-primary" /> Personal Account </h2> <div className="bg-primary/10 px-3 py-1 rounded-full text-xs font-medium text-primary flex items-center gap-1"> <ShieldCheck className="h-3.5 w-3.5" /> Account Settings </div> </div> <AccountSwitcher sessions={JSON.parse(JSON.stringify(deviceSessions))} /> <div className="grid gap-4"> <div className="space-y-3"> <div className="flex items-center gap-2"> <User className="h-4 w-4 text-primary" /> <h3 className="text-sm font-medium">User Profile</h3> </div> <UserCard session={JSON.parse(JSON.stringify(session))} activeSessions={JSON.parse(JSON.stringify(activeSessions))} // subscription={subscriptions.find( // (sub) => sub.status === "active" || sub.status === "trialing", // )} /> </div> </div> </div> </TabsContent> <TabsContent value="business" className="space-y-4"> <div className="grid gap-4"> <div className="flex items-center justify-between"> <h2 className="text-xl font-normal flex items-center gap-2"> <Building2 className="h-5 w-5 text-teal-600" /> Organization Management </h2> <div className="bg-primary/10 px-3 py-1 rounded-full text-xs font-medium text-primary flex items-center gap-1"> <Building2 className="h-3.5 w-3.5" /> Business Settings </div> </div> <OrganizationCard session={JSON.parse(JSON.stringify(session))} activeOrganization={JSON.parse(JSON.stringify(organization))} /> </div> </TabsContent> <TabsContent value="billing" className="space-y-4"> <div className="max-w-4xl mx-auto py-6"> <div className="mb-6"> <h1 className="text-2xl font-normal tracking-tight">Subscription & Billing</h1> <p className="mt-2 text-muted-foreground">Manage your subscription and billing information</p> </div> <CurrentSubscription /> </div> </TabsContent> </Tabs> </div> ); } ``` What is it that am doing wrong? I dont even expect something like username in better auth. This seems to originate from internal schema validation, possibly using Zod, during the build-time rendering of pages that use getSession() or getSafeSession(). I am using turborepo and next v14.*(tried downgrading but to no avail) :(
Author
Owner

@dosubot[bot] commented on GitHub (Jul 11, 2025):

You're not doing anything wrong in your code—this is a known issue with how BetterAuth's username plugin enforces schema validation. When you use BetterAuth (with the username plugin enabled), it always expects a non-empty username field, and this validation runs even during static/SSR builds in Next.js. Since there's no user/session context at build time, validation fails and you get { username: [ "Username can't be blank" ] } errors (plugin code).

The username plugin is not enabled by default—you or your team likely added it to your BetterAuth config. If you don't want username to be required, you need to remove the plugin or adjust its options. There is a requiredUsername option, but even with that, the plugin doesn't have SSR/build-time guards, so validation still runs at build time (PR).

Workarounds:

  • Avoid calling auth.api.getSession (or any BetterAuth API) in server components that run at build time.
  • Use a Provider pattern to inject session data at the app root, so session fetching/validation only happens at runtime (discussion).
  • If you must keep the username plugin, wrap your auth logic in a runtime check (e.g., if (typeof window !== "undefined" || process.env.NEXT_PHASE === "phase-production-server")) to avoid running it during static build.

If you want a code example for any of these patterns, let me know!

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Join Discord Share on X

<!-- gh-comment-id:3062145357 --> @dosubot[bot] commented on GitHub (Jul 11, 2025): <!-- Answer --> You're not doing anything wrong in your code—this is a known issue with how BetterAuth's username plugin enforces schema validation. When you use BetterAuth (with the username plugin enabled), it always expects a non-empty username field, and this validation runs even during static/SSR builds in Next.js. Since there's no user/session context at build time, validation fails and you get `{ username: [ "Username can't be blank" ] }` errors ([plugin code](https://github.com/better-auth/better-auth/blob/8ac324f64da1a63a48ea407b3aadee5816ccddf4/packages/better-auth/src/plugins/username/index.ts)). The username plugin is not enabled by default—you or your team likely added it to your BetterAuth config. If you don't want username to be required, you need to remove the plugin or adjust its options. There is a `requiredUsername` option, but even with that, the plugin doesn't have SSR/build-time guards, so validation still runs at build time ([PR](https://github.com/better-auth/better-auth/pull/3090)). **Workarounds:** - Avoid calling `auth.api.getSession` (or any BetterAuth API) in server components that run at build time. - Use a Provider pattern to inject session data at the app root, so session fetching/validation only happens at runtime ([discussion](https://github.com/better-auth/better-auth/issues/2768)). - If you must keep the username plugin, wrap your auth logic in a runtime check (e.g., `if (typeof window !== "undefined" || process.env.NEXT_PHASE === "phase-production-server")`) to avoid running it during static build. If you want a code example for any of these patterns, let me know! <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/92af50a3-5f1f-4338-802e-a8607b57d212?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/92af50a3-5f1f-4338-802e-a8607b57d212?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/92af50a3-5f1f-4338-802e-a8607b57d212?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/92af50a3-5f1f-4338-802e-a8607b57d212?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/92af50a3-5f1f-4338-802e-a8607b57d212?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/92af50a3-5f1f-4338-802e-a8607b57d212?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/92af50a3-5f1f-4338-802e-a8607b57d212?feedback_type=other)</sup>&nbsp;&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/3346)
Author
Owner

@marienjus commented on GitHub (Jul 11, 2025):

@dosu

You're not doing anything wrong in your code—this is a known issue with how BetterAuth's username plugin enforces schema validation. When you use BetterAuth (with the username plugin enabled), it always expects a non-empty username field, and this validation runs even during static/SSR builds in Next.js. Since there's no user/session context at build time, validation fails and you get { username: [ "Username can't be blank" ] } errors (plugin code).

The username plugin is not enabled by default—you or your team likely added it to your BetterAuth config. If you don't want username to be required, you need to remove the plugin or adjust its options. There is a requiredUsername option, but even with that, the plugin doesn't have SSR/build-time guards, so validation still runs at build time (PR).

Workarounds:

  • Avoid calling auth.api.getSession (or any BetterAuth API) in server components that run at build time.
  • Use a Provider pattern to inject session data at the app root, so session fetching/validation only happens at runtime (discussion).
  • If you must keep the username plugin, wrap your auth logic in a runtime check (e.g., if (typeof window !== "undefined" || process.env.NEXT_PHASE === "phase-production-server")) to avoid running it during static build.

If you want a code example for any of these patterns, let me know!

To reply, just mention @dosu.

How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Join Discord Share on X

NOT AT ALL.... MY CURRENT auth.ts file looks like this:

import { betterAuth, logger, type BetterAuthOptions } from "better-auth";
import {
  bearer,
  admin as adminPlugin,
  multiSession,
  organization,
  twoFactor,
  oAuthProxy,
  openAPI,
  oidcProvider,
  customSession,
  phoneNumber,
  createAuthMiddleware,
} from "better-auth/plugins";
import { prismaAdapter } from "better-auth/adapters/prisma";

import { mapUserRoleToBusiness } from "./role-management";
import { APP_BASE_URL } from "@workspace/utils";
// import { reactInvitationEmail } from "@/components/emails/invitation";
import { ac, roles } from "./permissions";
// import { reactResetPasswordEmail } from "@/components/emails/reset-password-email";
// import { resend } from "@workspace/notifications";
import { nextCookies } from "better-auth/next-js";
// import { initializeOrganizationPermissions } from "@/services/permission-service";
import { generateId } from "@workspace/utils"
import { setupSaleorResourcesForNewUser } from "@workspace/coremmerce/server-only";
import db from "@workspace/db";
import { PaymentMethodType } from "@workspace/db/enums"
import { atSMS } from "./africastalking";

const ALLOWED_ROLES = [
  "retailer",
  "wholesaler",
  "distributor",
  "user",
  "driver",
  "brand_owner",
] as const;

const businessRoles = new Set([
  "distributor",
  "retailer",
  "wholesaler",
  "brand_owner",
]);

const AUTH_CONFIG = {
  from: "online@alcorabooks.com",
  to: process.env.TEST_EMAIL || "",
  trustedOrigins: [
    "http://46.202.130.243:3000",
    "http://46.202.130.243:7700",
    "http://46.202.130.243:8000",
    "https://commerce.alcorabooks.com",
    "https://search.alcorabooks.com",
    "https://alcorabooks.com",
    "alcorabooks.com",
    "https://www.alcorabooks.com",
  ],
  appName: "Alcorabooks",
  allowedRoles: ALLOWED_ROLES,
};

export const auth: any = betterAuth({
  trustedOrigins: AUTH_CONFIG.trustedOrigins,
  appName: AUTH_CONFIG.appName,

  secret: "0c06fd6be5b8109d7e13630f5e7cabbe6de3ac8b2bb00d437805df9c4743b924",

  database: prismaAdapter(db, {
    provider: "postgresql",
  }),
  logger: {
    level: "debug",
    disabled: false
  },

  hooks: {
    after: createAuthMiddleware(async (ctx) => {
      // Handle newly authenticated users
      if (ctx.path.startsWith("/sign-up") || ctx.path.startsWith("/sign-in")) {
        const newSession = ctx.context.newSession;
        if (newSession) {
          ctx.setCookie(
            "Set-Cookie",
            `session_token=${ctx.context.authCookies.sessionToken}; Path=/; HttpOnly; SameSite=Strict`
          );
          // Check if user has pending onboarding
          // const org = await getOrganizationByUserId(newSession.user.id);
          const org = {} as any;
          if (org && org.metadata?.pendingOnboarding) {
            // Store onboarding state in session....
          }
        }
      }
    }),
  },

  databaseHooks: {
    user: {
      create: {
        before: async (user, ctx) => {
          const role = ctx?.body?.additionalFields?.role ?? "user";
          if (!ALLOWED_ROLES.includes(role)) {
            throw new Error("Invalid role assignment");
          }
          return { data: { ...user, role } };
        },
        after: async (user, ctx) => {
        }
      },
    },
    session: {
      create: {
        before: async (session, ctx) => {
          const membership = await db.member.findFirst({
            where: { userId: session.userId },
            include: { organization: true },
            orderBy: { createdAt: "asc" }, // prioritize oldest org if multiple
          });

          if (!membership) {
            console.warn("No organization found for user:", session.userId);
            return { data: session }; // return session as-is, no org
          }

          return {
            data: {
              ...session,
              activeOrganizationId: membership.organization.id,
            },
          };
        },
        after: async (session, ctx) => {
          console.log("Session after create:", session,);

          try {
            // 0) Bail early if this is just a sign‑in, not a sign‑up
            const isSignUp = Boolean(ctx?.context.newSession) && (ctx?.path.startsWith("/sign-up") || ctx?.path.startsWith("/auth/phone-number/verify"));
            if (!isSignUp) {
              logger.info("Sign‑in detected — skipping org & Saleor provisioning");
              return;
            }

            // 1) Fetch user & role
            const user = await db.user.findUnique({ where: { id: session.userId } });
            if (!user) return;

            // 2) Only business roles get orgs
            if (!businessRoles.has(user.role)) {
              logger.info(`Role=${user.role} — skipping org creation for B2C user`);
              return;
            }

            // 3) Check for existing org membership
            let membership = await db.member.findFirst({
              where: { userId: user.id },
              include: { organization: true },
            });

            // 4) True sign‑up + business role + no membership → create org + provision
            if (!membership) {
              const org = await db.organization.create({
                data: {
                  name: `${user.name}'s Organization`,
                  slug: generateId(),
                  businessType: mapUserRoleToBusiness(user.role),
                  paymentMethod: PaymentMethodType.MPESA,
                  members: { create: { userId: user.id, role: "OWNER" } },
                },
              });

              // Update session
              await db.session.update({
                where: { id: session.id },
                data: { activeOrganizationId: org.id },
              });

              (session as any).activeOrganizationId = org.id;

              // Provision Saleor resources
              await setupSaleorResourcesForNewUser(
                user.id,
                org.id,
                session.id
              );

              logger.info("New business sign‑up: org & Saleor provisioned");
            } else {
              logger.info("Existing business user sign‑up? Membership found — skipping provisioning");
            }
          } catch (error) {
            console.error("Error in session.create.after:", error);
          }
        },

      },
    },
  },
  user: {
    additionalFields: {
      role: {
        type: "string",
        required: true,
        defaultValue: "user",
        input: true,
      },
      premium: {
        type: "boolean",
        required: false,
        defaultValue: false,
        input: false,
      },
      // Phone number for SMS notifications not from better-auth auth plugins
      // @ref https://www.better-auth.com/docs/plugins/phone-number
      phoneNumber: {
        type: "string",
        required: false,
        input: true,
      },
      enableSMS: {
        type: "boolean",
        required: false,
        defaultValue: true,
        input: true,
      },
    },
    changeEmail: {
      enabled: true,
      sendChangeEmailVerification: async (
        { user, newEmail, url, token },
        request
      ) => {
        console.log("TODO: CHANGE EMAIL");
      },
    },
  },

  session: {
    cookieCache: {
      enabled: true,
      maxAge: 60 * 60 * 24 * 7,
    },
    expiresIn: 60 * 60 * 24 * 7,
    updateAge: 60 * 60 * 24, // (every 1 day the session expiration is updated)
    additionalFields: {
      saleorChannelId: {
        type: "string",
        required: false,
      },
      saleorChannelSlug: {
        type: "string",
        required: false,
      },
      organizationId: {
        type: "string",
        required: false,
      },
      organizationName: {
        type: "string",
        required: false,
      },
      organizationSlug: {
        type: "string",
        required: false,
      },
      businessType: {
        type: "string",
        required: false,
      },
      warehouseId: {
        type: "string",
        required: false,
      },
    },
  },

  emailVerification: {
    sendOnSignUp: true,
    autoSignInAfterVerification: true,
    async sendVerificationEmail({ user, url }) {
      // const verificationUrl = `${process.env.BETTER_AUTH_URL}/api/auth/verify-email?token=${token}&callbackURL=${process.env.EMAIL_VERIFICATION_CALLBACK_URL}`;

      // const res = await resend.emails.send({
      //   from: AUTH_CONFIG.from,
      //   to: AUTH_CONFIG.to || user.email,
      //   subject: "Verify your email address",
      //   html: `<a href="${url}">Verify your email address</a>`,
      // });
      console.log(user.email);
    },
  },
  // account: {
  //   accountLinking: {
  //     trustedProviders: ["google", "microsoft"],
  //   },
  // },
  emailAndPassword: {
    enabled: true,
    // requireEmailVerification: true,
    // async sendResetPassword({ user, url }) {
    //   await resend.emails.send({
    //     from: AUTH_CONFIG.from,
    //     to: user.email,
    //     subject: "Reset your password",
    //     react: reactResetPasswordEmail({
    //       username: user.email,
    //       resetLink: url,
    //     }),
    //   });
    // },
  },
  socialProviders: {
    facebook: {
      clientId: process.env.FACEBOOK_CLIENT_ID || "",
      clientSecret: process.env.FACEBOOK_CLIENT_SECRET || "",
    },
    google: {
      clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "",
      clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
    },
    microsoft: {
      clientId: process.env.MICROSOFT_CLIENT_ID || "",
      clientSecret: process.env.MICROSOFT_CLIENT_SECRET || "",
    },
  },
  plugins: [
    oidcProvider({
      loginPage: "/sign-in",
      requirePKCE: true,
      consentPage: "/oauth/consent",
      metadata: {
        issuer: APP_BASE_URL,
        authorization_endpoint: `${APP_BASE_URL}/oauth/authorize`,
        token_endpoint: `${APP_BASE_URL}/oauth/token`,
        userinfo_endpoint: `${APP_BASE_URL}/userinfo`,
        jwks_uri: `${APP_BASE_URL}/.well-known/jwks.json`,
        response_types_supported: ["code"],
        subject_types_supported: ["public"],
        id_token_signing_alg_values_supported: ["RS256"],
      },
      // Allow Saleor as a client
      allowDynamicClientRegistration: true,
    }),
    organization({
      allowUserToCreateOrganization: async (user) => {
        return businessRoles.has((user as any).role);
      },
      membershipLimit: 200_0,
      organizationLimit: 10,
      ac: ac,
      roles: {
        owner: roles.owner,
        admin: roles.admin,
        wholesaler: roles.wholesaler,
        retailer: roles.retailer,
        bartender: roles.bartender,
        cashier: roles.cashier,
        finance: roles.finance,
        driver: roles.driver,
      },
      // Custom schema configuration for Organization
      schema: {
        organization: {
          fields: {
            // Define how organization.metadata maps to our custom fields
            metadata: "metadata",
          },
          // Additional fields/columns not automatically handled by the plugin
          additionalFields: {
            businessType: {
              type: "string",
              required: false,
              defaultValue: "",
            },
            description: {
              type: "string",
              required: false,
            },
            logo: {
              type: "string",
              required: false,
            },
            city: {
              type: "string",
              required: false,
            },
            address: {
              type: "string",
              required: false,
            },
            phoneNumber: {
              type: "string",
              required: false,
            },
            enableSMS: {
              type: "boolean",
              required: false,
              defaultValue: true,
            },
            paymentMethod: {
              type: "string",
              required: false,
            },
            subscriptionPlan: {
              type: "string",
              required: false,
            },
            onboardingComplete: {
              type: "boolean",
              required: false,
              defaultValue: false,
            },
            channel: {
              type: "string",
              required: false,
              defaultValue: true,
            },
          },
        },
      },
      // async sendInvitationEmail(data) {
      //   // await resend.emails.send({
      //   //   from: AUTH_CONFIG.from,
      //   //   to: data.email,
      //   //   subject: "You've been invited to join an organization",
      //   //   react: reactInvitationEmail({
      //   //     username: data.email,
      //   //     invitedByUsername: data.inviter.user.name,
      //   //     invitedByEmail: data.inviter.user.email,
      //   //     teamName: data.organization.name,
      //   //     inviteLink:
      //   //       process.env.NODE_ENV === "development"
      //   //         ? `http://localhost:3000/accept-invitation/${data.id}`
      //   //         : `${process.env.BETTER_AUTH_URL || APP_BASE_URL
      //   //         }/accept-invitation/${data.id}`,
      //   //   }),
      //   // });
      // },
      organizationCreation: {
        beforeCreate: async ({ organization, user }) => {
          const extendedOrg = {
            ...organization,
            slug: organization.slug || generateId(),
            paymentMethod: PaymentMethodType.MPESA,
          }
          console.log("ext-org.user", extendedOrg, user, user.id)
          return {
            data: extendedOrg,
          };
        },
        afterCreate: async ({ organization, user }) => {
          // TODO: Attach Saleor resources
          console.log(organization, "afterCreate")
        },
      },
    }),

    // customSession(async ({ user, session }) => {
    //   // First, get the organization from the active organization ID
    //   const org = await db.organization.findUnique({
    //     where: { id: (session as any)?.activeOrganizationId || "" },
    //     include: {
    //       saleorChannels: { where: { isActive: true }, take: 1 },
    //       warehouses: { take: 1 },
    //     },
    //   });

    //   // Get the default channel
    //   const defaultChannel = org?.saleorChannels?.[0];
    //   const defaultWarehouse = org?.warehouses?.[0];

    //   const transformedSession: any = { ...session };

    //   transformedSession.user = user;
    //   transformedSession.role = (user as any)?.role;
    //   transformedSession.organizationId = org?.id;
    //   transformedSession.organizationName = org?.name;
    //   transformedSession.organizationSlug = org?.slug;
    //   transformedSession.businessType = org?.businessType;

    //   // Adding chann properties last to ensure they aren't overridden
    //   if (defaultChannel) {
    //     transformedSession.saleorChannelId = defaultChannel.saleorChannelId;
    //     transformedSession.saleorChannelSlug = defaultChannel.slug;
    //   }

    //   if (defaultWarehouse) {
    //     transformedSession.warehouseId = defaultWarehouse.saleorWarehouseId;
    //   }

    //   logger.info(transformedSession)
    //   return transformedSession;
    // }),
    twoFactor({
      otpOptions: {
        async sendOTP({ user, otp }) {
          // await resend.emails.send({
          //   from: AUTH_CONFIG.from,
          //   to: user.email,
          //   subject: "Your OTP",
          //   html: `Your OTP is ${otp}`,
          // });
        },
      },
    }),
    phoneNumber({
      allowedAttempts: 3,
      sendOTP: async ({ phoneNumber, code }, request) => {
        console.log(`Sending OTP ${code} to ${phoneNumber}`)
        try {
          const res = await atSMS.send({
            to: [phoneNumber],
            message: `Your Alcora verification code is: ${code}`,
            // Optional: senderId: 'Alcora' (must be pre-approved on AT)
          })
          console.log('AT SMS sent:', res)
        } catch (err) {
          console.error('AT SMS failed:', err)
          // Optional: throw new Error("Failed to send SMS")
        }
      },
      signUpOnVerification: {
        getTempEmail: (phoneNumber) => {
          return `${phoneNumber}@alcorabooks.com`
        },
        getTempName: (phoneNumber) => {
          return phoneNumber
        }
      },
      callbackOnVerification: async ({ phoneNumber, user }, request) => {
        // ...
      },
      otpLength: 4,
      expiresIn: 60 * 5, //5mins
    }),
    openAPI(),
    bearer(),
    adminPlugin({
      ac: ac,
      roles,
      adminRoles: ["owner", "admin"],
      impersonationSessionDuration: 60 * 60 * 24 * 7,
    }),
    multiSession(),
    oAuthProxy(),
    nextCookies(),
  ],
} satisfies BetterAuthOptions);

export const createAuth = () => {
  return auth;
};

export type Auth = ReturnType<typeof createAuth>;

WHERE IS USERNAME? my schema:

model User {
    id                   String   @id @default(cuid())
    name                 String
    email                String   @unique
    emailVerified        Boolean  @default(false)
    image                String?
    premium              Boolean  @default(false)
    role                 UserRole @default(user)
    banned               Boolean  @default(false)
    banReason            String?
    banExpires           Int? // Unix timestamp for ban expiration
    twoFactorEnabled     Boolean  @default(false)
    twoFactorSecret      String?
    twoFactorBackupCodes String?
    phoneNumber          String?  @unique // SMS Notifs. Primary B2C contact channel
    phoneNumberVerified  Boolean  @default(false)
    enableSMS            Boolean  @default(true)

    settings Json? @default("{}")

    createdAt DateTime @default(now())
    updatedAt DateTime @updatedAt

    sessions            Session[]
    accounts            Account[]
    organizationMembers Member[]
    OAuthApplication    OAuthApplication[]
    OAuthAccessToken    OAuthAccessToken[]
    OAuthConsent        OAuthConsent[]
    auditLogs           StockAuditLog[]
    auditBatches        StockAuditBatch[]
    driver              Driver?
    customer            Customer?
    salesVisit          SalesVisit[]

    paymentIntents PaymentIntent[]

    bartenderShifts BartenderShift[] @relation("BartenderShifts")
    approvedShifts  BartenderShift[] @relation("ApprovedShifts")
}

enum UserRole {
    user
    admin
    distributor
    wholesaler
    retailer
    driver //driver_rider
    brand_owner
}

model Session {
    id                   String   @id @default(cuid())
    userId               String
    token                String   @unique // better-auth uses this as unique identifier
    expiresAt            DateTime
    ipAddress            String?
    userAgent            String?
    impersonatedBy       String?
    activeOrganizationId String? // Required by better-auth for active org
    createdAt            DateTime @default(now())
    updatedAt            DateTime @updatedAt

    user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

WHERE IS THE ISSUE WITH ALL THIS?

<!-- gh-comment-id:3062227762 --> @marienjus commented on GitHub (Jul 11, 2025): @dosu > You're not doing anything wrong in your code—this is a known issue with how BetterAuth's username plugin enforces schema validation. When you use BetterAuth (with the username plugin enabled), it always expects a non-empty username field, and this validation runs even during static/SSR builds in Next.js. Since there's no user/session context at build time, validation fails and you get `{ username: [ "Username can't be blank" ] }` errors ([plugin code](https://github.com/better-auth/better-auth/blob/8ac324f64da1a63a48ea407b3aadee5816ccddf4/packages/better-auth/src/plugins/username/index.ts)). > > The username plugin is not enabled by default—you or your team likely added it to your BetterAuth config. If you don't want username to be required, you need to remove the plugin or adjust its options. There is a `requiredUsername` option, but even with that, the plugin doesn't have SSR/build-time guards, so validation still runs at build time ([PR](https://github.com/better-auth/better-auth/pull/3090)). > > **Workarounds:** > > * Avoid calling `auth.api.getSession` (or any BetterAuth API) in server components that run at build time. > * Use a Provider pattern to inject session data at the app root, so session fetching/validation only happens at runtime ([discussion](https://github.com/better-auth/better-auth/issues/2768)). > * If you must keep the username plugin, wrap your auth logic in a runtime check (e.g., `if (typeof window !== "undefined" || process.env.NEXT_PHASE === "phase-production-server")`) to avoid running it during static build. > > If you want a code example for any of these patterns, let me know! > > _To reply, just mention [@dosu](https://go.dosu.dev/dosubot)._ > > How did I do? [Good](https://app.dosu.dev/response-feedback/92af50a3-5f1f-4338-802e-a8607b57d212?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/92af50a3-5f1f-4338-802e-a8607b57d212?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/92af50a3-5f1f-4338-802e-a8607b57d212?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/92af50a3-5f1f-4338-802e-a8607b57d212?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/92af50a3-5f1f-4338-802e-a8607b57d212?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/92af50a3-5f1f-4338-802e-a8607b57d212?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/92af50a3-5f1f-4338-802e-a8607b57d212?feedback_type=other)  [![Join Discord](https://camo.githubusercontent.com/d562c2df5e4567ea41d37f1d91a4da0839d8341037045b103b2da268998de17f/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6a6f696e2d3538363546323f6c6f676f3d646973636f7264266c6f676f436f6c6f723d7768697465266c6162656c3d)](https://go.dosu.dev/discord-bot) [![Share on X](https://camo.githubusercontent.com/4649f705e2396d6f328dbc8e800a0509c9c17b3b20253b36706c393df124b93d/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f582d73686172652d626c61636b)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/3346) NOT AT ALL.... MY CURRENT auth.ts file looks like this: ```ts import { betterAuth, logger, type BetterAuthOptions } from "better-auth"; import { bearer, admin as adminPlugin, multiSession, organization, twoFactor, oAuthProxy, openAPI, oidcProvider, customSession, phoneNumber, createAuthMiddleware, } from "better-auth/plugins"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { mapUserRoleToBusiness } from "./role-management"; import { APP_BASE_URL } from "@workspace/utils"; // import { reactInvitationEmail } from "@/components/emails/invitation"; import { ac, roles } from "./permissions"; // import { reactResetPasswordEmail } from "@/components/emails/reset-password-email"; // import { resend } from "@workspace/notifications"; import { nextCookies } from "better-auth/next-js"; // import { initializeOrganizationPermissions } from "@/services/permission-service"; import { generateId } from "@workspace/utils" import { setupSaleorResourcesForNewUser } from "@workspace/coremmerce/server-only"; import db from "@workspace/db"; import { PaymentMethodType } from "@workspace/db/enums" import { atSMS } from "./africastalking"; const ALLOWED_ROLES = [ "retailer", "wholesaler", "distributor", "user", "driver", "brand_owner", ] as const; const businessRoles = new Set([ "distributor", "retailer", "wholesaler", "brand_owner", ]); const AUTH_CONFIG = { from: "online@alcorabooks.com", to: process.env.TEST_EMAIL || "", trustedOrigins: [ "http://46.202.130.243:3000", "http://46.202.130.243:7700", "http://46.202.130.243:8000", "https://commerce.alcorabooks.com", "https://search.alcorabooks.com", "https://alcorabooks.com", "alcorabooks.com", "https://www.alcorabooks.com", ], appName: "Alcorabooks", allowedRoles: ALLOWED_ROLES, }; export const auth: any = betterAuth({ trustedOrigins: AUTH_CONFIG.trustedOrigins, appName: AUTH_CONFIG.appName, secret: "0c06fd6be5b8109d7e13630f5e7cabbe6de3ac8b2bb00d437805df9c4743b924", database: prismaAdapter(db, { provider: "postgresql", }), logger: { level: "debug", disabled: false }, hooks: { after: createAuthMiddleware(async (ctx) => { // Handle newly authenticated users if (ctx.path.startsWith("/sign-up") || ctx.path.startsWith("/sign-in")) { const newSession = ctx.context.newSession; if (newSession) { ctx.setCookie( "Set-Cookie", `session_token=${ctx.context.authCookies.sessionToken}; Path=/; HttpOnly; SameSite=Strict` ); // Check if user has pending onboarding // const org = await getOrganizationByUserId(newSession.user.id); const org = {} as any; if (org && org.metadata?.pendingOnboarding) { // Store onboarding state in session.... } } } }), }, databaseHooks: { user: { create: { before: async (user, ctx) => { const role = ctx?.body?.additionalFields?.role ?? "user"; if (!ALLOWED_ROLES.includes(role)) { throw new Error("Invalid role assignment"); } return { data: { ...user, role } }; }, after: async (user, ctx) => { } }, }, session: { create: { before: async (session, ctx) => { const membership = await db.member.findFirst({ where: { userId: session.userId }, include: { organization: true }, orderBy: { createdAt: "asc" }, // prioritize oldest org if multiple }); if (!membership) { console.warn("No organization found for user:", session.userId); return { data: session }; // return session as-is, no org } return { data: { ...session, activeOrganizationId: membership.organization.id, }, }; }, after: async (session, ctx) => { console.log("Session after create:", session,); try { // 0) Bail early if this is just a sign‑in, not a sign‑up const isSignUp = Boolean(ctx?.context.newSession) && (ctx?.path.startsWith("/sign-up") || ctx?.path.startsWith("/auth/phone-number/verify")); if (!isSignUp) { logger.info("Sign‑in detected — skipping org & Saleor provisioning"); return; } // 1) Fetch user & role const user = await db.user.findUnique({ where: { id: session.userId } }); if (!user) return; // 2) Only business roles get orgs if (!businessRoles.has(user.role)) { logger.info(`Role=${user.role} — skipping org creation for B2C user`); return; } // 3) Check for existing org membership let membership = await db.member.findFirst({ where: { userId: user.id }, include: { organization: true }, }); // 4) True sign‑up + business role + no membership → create org + provision if (!membership) { const org = await db.organization.create({ data: { name: `${user.name}'s Organization`, slug: generateId(), businessType: mapUserRoleToBusiness(user.role), paymentMethod: PaymentMethodType.MPESA, members: { create: { userId: user.id, role: "OWNER" } }, }, }); // Update session await db.session.update({ where: { id: session.id }, data: { activeOrganizationId: org.id }, }); (session as any).activeOrganizationId = org.id; // Provision Saleor resources await setupSaleorResourcesForNewUser( user.id, org.id, session.id ); logger.info("New business sign‑up: org & Saleor provisioned"); } else { logger.info("Existing business user sign‑up? Membership found — skipping provisioning"); } } catch (error) { console.error("Error in session.create.after:", error); } }, }, }, }, user: { additionalFields: { role: { type: "string", required: true, defaultValue: "user", input: true, }, premium: { type: "boolean", required: false, defaultValue: false, input: false, }, // Phone number for SMS notifications not from better-auth auth plugins // @ref https://www.better-auth.com/docs/plugins/phone-number phoneNumber: { type: "string", required: false, input: true, }, enableSMS: { type: "boolean", required: false, defaultValue: true, input: true, }, }, changeEmail: { enabled: true, sendChangeEmailVerification: async ( { user, newEmail, url, token }, request ) => { console.log("TODO: CHANGE EMAIL"); }, }, }, session: { cookieCache: { enabled: true, maxAge: 60 * 60 * 24 * 7, }, expiresIn: 60 * 60 * 24 * 7, updateAge: 60 * 60 * 24, // (every 1 day the session expiration is updated) additionalFields: { saleorChannelId: { type: "string", required: false, }, saleorChannelSlug: { type: "string", required: false, }, organizationId: { type: "string", required: false, }, organizationName: { type: "string", required: false, }, organizationSlug: { type: "string", required: false, }, businessType: { type: "string", required: false, }, warehouseId: { type: "string", required: false, }, }, }, emailVerification: { sendOnSignUp: true, autoSignInAfterVerification: true, async sendVerificationEmail({ user, url }) { // const verificationUrl = `${process.env.BETTER_AUTH_URL}/api/auth/verify-email?token=${token}&callbackURL=${process.env.EMAIL_VERIFICATION_CALLBACK_URL}`; // const res = await resend.emails.send({ // from: AUTH_CONFIG.from, // to: AUTH_CONFIG.to || user.email, // subject: "Verify your email address", // html: `<a href="${url}">Verify your email address</a>`, // }); console.log(user.email); }, }, // account: { // accountLinking: { // trustedProviders: ["google", "microsoft"], // }, // }, emailAndPassword: { enabled: true, // requireEmailVerification: true, // async sendResetPassword({ user, url }) { // await resend.emails.send({ // from: AUTH_CONFIG.from, // to: user.email, // subject: "Reset your password", // react: reactResetPasswordEmail({ // username: user.email, // resetLink: url, // }), // }); // }, }, socialProviders: { facebook: { clientId: process.env.FACEBOOK_CLIENT_ID || "", clientSecret: process.env.FACEBOOK_CLIENT_SECRET || "", }, google: { clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "", clientSecret: process.env.GOOGLE_CLIENT_SECRET || "", }, microsoft: { clientId: process.env.MICROSOFT_CLIENT_ID || "", clientSecret: process.env.MICROSOFT_CLIENT_SECRET || "", }, }, plugins: [ oidcProvider({ loginPage: "/sign-in", requirePKCE: true, consentPage: "/oauth/consent", metadata: { issuer: APP_BASE_URL, authorization_endpoint: `${APP_BASE_URL}/oauth/authorize`, token_endpoint: `${APP_BASE_URL}/oauth/token`, userinfo_endpoint: `${APP_BASE_URL}/userinfo`, jwks_uri: `${APP_BASE_URL}/.well-known/jwks.json`, response_types_supported: ["code"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["RS256"], }, // Allow Saleor as a client allowDynamicClientRegistration: true, }), organization({ allowUserToCreateOrganization: async (user) => { return businessRoles.has((user as any).role); }, membershipLimit: 200_0, organizationLimit: 10, ac: ac, roles: { owner: roles.owner, admin: roles.admin, wholesaler: roles.wholesaler, retailer: roles.retailer, bartender: roles.bartender, cashier: roles.cashier, finance: roles.finance, driver: roles.driver, }, // Custom schema configuration for Organization schema: { organization: { fields: { // Define how organization.metadata maps to our custom fields metadata: "metadata", }, // Additional fields/columns not automatically handled by the plugin additionalFields: { businessType: { type: "string", required: false, defaultValue: "", }, description: { type: "string", required: false, }, logo: { type: "string", required: false, }, city: { type: "string", required: false, }, address: { type: "string", required: false, }, phoneNumber: { type: "string", required: false, }, enableSMS: { type: "boolean", required: false, defaultValue: true, }, paymentMethod: { type: "string", required: false, }, subscriptionPlan: { type: "string", required: false, }, onboardingComplete: { type: "boolean", required: false, defaultValue: false, }, channel: { type: "string", required: false, defaultValue: true, }, }, }, }, // async sendInvitationEmail(data) { // // await resend.emails.send({ // // from: AUTH_CONFIG.from, // // to: data.email, // // subject: "You've been invited to join an organization", // // react: reactInvitationEmail({ // // username: data.email, // // invitedByUsername: data.inviter.user.name, // // invitedByEmail: data.inviter.user.email, // // teamName: data.organization.name, // // inviteLink: // // process.env.NODE_ENV === "development" // // ? `http://localhost:3000/accept-invitation/${data.id}` // // : `${process.env.BETTER_AUTH_URL || APP_BASE_URL // // }/accept-invitation/${data.id}`, // // }), // // }); // }, organizationCreation: { beforeCreate: async ({ organization, user }) => { const extendedOrg = { ...organization, slug: organization.slug || generateId(), paymentMethod: PaymentMethodType.MPESA, } console.log("ext-org.user", extendedOrg, user, user.id) return { data: extendedOrg, }; }, afterCreate: async ({ organization, user }) => { // TODO: Attach Saleor resources console.log(organization, "afterCreate") }, }, }), // customSession(async ({ user, session }) => { // // First, get the organization from the active organization ID // const org = await db.organization.findUnique({ // where: { id: (session as any)?.activeOrganizationId || "" }, // include: { // saleorChannels: { where: { isActive: true }, take: 1 }, // warehouses: { take: 1 }, // }, // }); // // Get the default channel // const defaultChannel = org?.saleorChannels?.[0]; // const defaultWarehouse = org?.warehouses?.[0]; // const transformedSession: any = { ...session }; // transformedSession.user = user; // transformedSession.role = (user as any)?.role; // transformedSession.organizationId = org?.id; // transformedSession.organizationName = org?.name; // transformedSession.organizationSlug = org?.slug; // transformedSession.businessType = org?.businessType; // // Adding chann properties last to ensure they aren't overridden // if (defaultChannel) { // transformedSession.saleorChannelId = defaultChannel.saleorChannelId; // transformedSession.saleorChannelSlug = defaultChannel.slug; // } // if (defaultWarehouse) { // transformedSession.warehouseId = defaultWarehouse.saleorWarehouseId; // } // logger.info(transformedSession) // return transformedSession; // }), twoFactor({ otpOptions: { async sendOTP({ user, otp }) { // await resend.emails.send({ // from: AUTH_CONFIG.from, // to: user.email, // subject: "Your OTP", // html: `Your OTP is ${otp}`, // }); }, }, }), phoneNumber({ allowedAttempts: 3, sendOTP: async ({ phoneNumber, code }, request) => { console.log(`Sending OTP ${code} to ${phoneNumber}`) try { const res = await atSMS.send({ to: [phoneNumber], message: `Your Alcora verification code is: ${code}`, // Optional: senderId: 'Alcora' (must be pre-approved on AT) }) console.log('AT SMS sent:', res) } catch (err) { console.error('AT SMS failed:', err) // Optional: throw new Error("Failed to send SMS") } }, signUpOnVerification: { getTempEmail: (phoneNumber) => { return `${phoneNumber}@alcorabooks.com` }, getTempName: (phoneNumber) => { return phoneNumber } }, callbackOnVerification: async ({ phoneNumber, user }, request) => { // ... }, otpLength: 4, expiresIn: 60 * 5, //5mins }), openAPI(), bearer(), adminPlugin({ ac: ac, roles, adminRoles: ["owner", "admin"], impersonationSessionDuration: 60 * 60 * 24 * 7, }), multiSession(), oAuthProxy(), nextCookies(), ], } satisfies BetterAuthOptions); export const createAuth = () => { return auth; }; export type Auth = ReturnType<typeof createAuth>; ``` WHERE IS USERNAME? my schema: ```prisma model User { id String @id @default(cuid()) name String email String @unique emailVerified Boolean @default(false) image String? premium Boolean @default(false) role UserRole @default(user) banned Boolean @default(false) banReason String? banExpires Int? // Unix timestamp for ban expiration twoFactorEnabled Boolean @default(false) twoFactorSecret String? twoFactorBackupCodes String? phoneNumber String? @unique // SMS Notifs. Primary B2C contact channel phoneNumberVerified Boolean @default(false) enableSMS Boolean @default(true) settings Json? @default("{}") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt sessions Session[] accounts Account[] organizationMembers Member[] OAuthApplication OAuthApplication[] OAuthAccessToken OAuthAccessToken[] OAuthConsent OAuthConsent[] auditLogs StockAuditLog[] auditBatches StockAuditBatch[] driver Driver? customer Customer? salesVisit SalesVisit[] paymentIntents PaymentIntent[] bartenderShifts BartenderShift[] @relation("BartenderShifts") approvedShifts BartenderShift[] @relation("ApprovedShifts") } enum UserRole { user admin distributor wholesaler retailer driver //driver_rider brand_owner } model Session { id String @id @default(cuid()) userId String token String @unique // better-auth uses this as unique identifier expiresAt DateTime ipAddress String? userAgent String? impersonatedBy String? activeOrganizationId String? // Required by better-auth for active org createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) } ``` WHERE IS THE ISSUE WITH ALL THIS?
Author
Owner

@dosubot[bot] commented on GitHub (Jul 11, 2025):

You are not doing anything wrong in your config—BetterAuth only enforces username validation if the username plugin is explicitly included in your plugins array. Based on the code and docs, none of the plugins you listed (organization, admin, etc.) add a username requirement, and the username plugin is not enabled by default docs.

If you’re seeing { username: ["Username can't be blank"] } errors but don’t have the username plugin in your config or a username field in your schema, it almost always means the plugin is being included somewhere else in your monorepo or config (possibly via a wrapper, dynamic import, or shared config). No other plugin or core logic in BetterAuth adds this requirement source.

Here’s how to debug this:

  1. Search your entire monorepo for username( or from "better-auth/plugins" to find any import or usage of the username plugin, even in shared or environment-specific files.
  2. Check for any dynamic imports, config merging, or wrapper modules that might add plugins at runtime.
  3. Make sure no build scripts or codegen tools modify your BetterAuth config before build.
  4. If you use any shared auth config (e.g., in a package like @workspace/auth), check there too.

If you strip your project down to just the config and schema you posted, the error should disappear. If it doesn’t, let me know and we can dig deeper. If you want a step-by-step checklist or a code example for runtime guards/Provider patterns to avoid SSR validation, just ask!

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Join Discord Share on X

<!-- gh-comment-id:3062251151 --> @dosubot[bot] commented on GitHub (Jul 11, 2025): <!-- Answer --> You are not doing anything wrong in your config—BetterAuth only enforces username validation if the `username` plugin is explicitly included in your plugins array. Based on the code and docs, none of the plugins you listed (organization, admin, etc.) add a username requirement, and the username plugin is not enabled by default [docs](https://better-auth.com/docs/plugins/username). If you’re seeing `{ username: ["Username can't be blank"] }` errors but don’t have the username plugin in your config or a `username` field in your schema, it almost always means the plugin is being included somewhere else in your monorepo or config (possibly via a wrapper, dynamic import, or shared config). No other plugin or core logic in BetterAuth adds this requirement [source](https://github.com/better-auth/better-auth/blob/8ac324f64da1a63a48ea407b3aadee5816ccddf4/packages/better-auth/src/plugins/username/index.ts). Here’s how to debug this: 1. Search your entire monorepo for `username(` or `from "better-auth/plugins"` to find any import or usage of the username plugin, even in shared or environment-specific files. 2. Check for any dynamic imports, config merging, or wrapper modules that might add plugins at runtime. 3. Make sure no build scripts or codegen tools modify your BetterAuth config before build. 4. If you use any shared auth config (e.g., in a package like `@workspace/auth`), check there too. If you strip your project down to just the config and schema you posted, the error should disappear. If it doesn’t, let me know and we can dig deeper. If you want a step-by-step checklist or a code example for runtime guards/Provider patterns to avoid SSR validation, just ask! <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/d4f12397-82f8-4361-b473-1c4dca5274ad?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/d4f12397-82f8-4361-b473-1c4dca5274ad?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/d4f12397-82f8-4361-b473-1c4dca5274ad?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/d4f12397-82f8-4361-b473-1c4dca5274ad?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/d4f12397-82f8-4361-b473-1c4dca5274ad?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/d4f12397-82f8-4361-b473-1c4dca5274ad?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/d4f12397-82f8-4361-b473-1c4dca5274ad?feedback_type=other)</sup>&nbsp;&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/3346)
Author
Owner

@marienjus commented on GitHub (Jul 11, 2025):

Eager imports of a server-side auth module (@workspace/auth) during the build phase.

// 🔥 Dynamically import auth
const mod = await import("@workspace/auth");
const session = await Promise.race([
  mod.auth.api.getSession({ headers: standardHeaders }),
  new Promise((_, reject) =>
    setTimeout(() => reject(new Error("Session timeout")), 5000)
  ),
]);

has fixed it...!

<!-- gh-comment-id:3062427171 --> @marienjus commented on GitHub (Jul 11, 2025): Eager imports of a server-side auth module (@workspace/auth) during the build phase. // 🔥 Dynamically import auth const mod = await import("@workspace/auth"); const session = await Promise.race([ mod.auth.api.getSession({ headers: standardHeaders }), new Promise((_, reject) => setTimeout(() => reject(new Error("Session timeout")), 5000) ), ]); has fixed it...!
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#9575