@naeemba/next-starter
Opinionated Next.js + Drizzle + Better Auth starter, shipped as a versioned npm package instead of a clone-and-fork template. Add it as a dependency, set env vars, create a few shim files, and you have working magic-link email sign-in. Bump the package version to pull in fixes.
If you're upgrading, see UPGRADING.md.
Sign-in methods
| Method | Enable via | Required env |
|---|---|---|
| Magic link | Default (or createAuth({ magicLink: {...} })) |
RESEND_API_KEY in production |
createAuth({ google: {} }) |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
|
| Passkey | createAuth({ passkey: { rpName: 'Your App' } }) |
none (uses BETTER_AUTH_URL) |
Each method is opt-in. Enabling one does not require the others.
Install
npm install @naeemba/next-starterThen scaffold the required shim files automatically:
npx @naeemba/next-starter initOr skip the CLI and create them by hand (see Setup files in your app).
Peer dependencies: next >= 16, react >= 19, react-dom >= 19. Node >= 20.
Env vars
DATABASE_URL=postgres://user:pass@host:5432/db
BETTER_AUTH_SECRET=<32+ char random string> # openssl rand -hex 32
BETTER_AUTH_URL=https://app.example.com
EMAIL_FROM=auth@example.com # optional in dev, required for Resend in prod
RESEND_API_KEY=... # optional — when unset, magic links log to stdout
# Optional: NEXT_PUBLIC_BETTER_AUTH_URL — only set when the public URL the
# browser must call differs from window.location.origin (e.g. a proxy in
# front with a different hostname). Otherwise the client derives it at runtime.
# Note: postgres, @react-email/*, @better-auth/passkey, and resend are optional
# peer dependencies. Install only the ones you actually use — see UPGRADING.md.Setup files in your app
lib/auth.ts
import { createAuth } from "@naeemba/next-starter/auth"
export const auth = await createAuth()createAuth is async (since 0.7.0) so it can import() ESM-only optional peers like @better-auth/passkey. Top-level await resolves once at module init in Next 16 server modules; downstream importers see auth as a resolved Auth instance, not a Promise.
createAuth accepts options for magicLink (custom expiry, allowlist, custom email template), session (override session cookie / expiry settings), google, passkey, singleAdmin (lock sign-in to one or more emails), accountLinking, rateLimit (better-auth's rate-limit knob; BETTER_AUTH_RATE_LIMIT_DISABLED=1 env force-disables for local dev), and transport (BYO email delivery — replaces the built-in Resend/console dispatch for magic-link mail).
passkey also forwards registration and authentication to the underlying plugin, so you can opt into WebAuthn extensions. To enable the PRF extension (lets a passkey derive a stable client-side secret, e.g. for wrapping an encryption key):
createAuth({ passkey: { rpName: "Your App", registration: { extensions: { prf: {} } } } })registration.extensions / authentication.extensions are typed against the standard AuthenticationExtensionsClientInputs, so this type-checks without @better-auth/passkey installed.
lib/auth-client.ts
"use client"
import { createAuthClient } from "@naeemba/next-starter/client"
import { passkeyClient } from "@better-auth/passkey/client"
export const authClient = createAuthClient({ passkey: passkeyClient })
export const { signIn, signOut, useSession } = authClientDrop the
passkeyClientimport (and thepasskey:field) to skip passkey support — the consumer bundle then excludes@better-auth/passkeyentirely.
baseURLresolution:opts.baseURL→NEXT_PUBLIC_BETTER_AUTH_URL→window.location.origin. For same-origin deployments you can drop both the env var and the option. Set one only when the public URL the client must call differs from what the browser sees.
lib/auth-server.ts
import { createServer } from "@naeemba/next-starter/server"
import { auth } from "./auth"
export const { getSession, requireSession } = createServer(auth)app/api/auth/[...all]/route.ts
import { createAuthRoute } from "@naeemba/next-starter/auth-route"
import { auth } from "@/lib/auth"
export const { GET, POST } = createAuthRoute(auth)app/sign-in/page.tsx
import { SignInPage } from "@naeemba/next-starter/pages/sign-in"
import { authClient } from "@/lib/auth-client"
export default function Page() {
return <SignInPage authClient={authClient} errorCallbackUrl="/sign-in/error" />
}SignInPage reads ?callbackUrl= from the URL query string and forwards it as the post-sign-in redirect (falling back to the callbackUrl prop, then "/"). Cross-origin and protocol-relative values are dropped silently to prevent open-redirect abuse. Set callbackParam to use a different query name. Set errorCallbackUrl to redirect to a friendly page when the magic-link verify endpoint fails — see the SignInErrorPage recipe below.
app/sign-in/error/page.tsx
import { SignInErrorPage } from "@naeemba/next-starter/pages/sign-in"
export default function Page() {
return <SignInErrorPage />
}Renders a heading + user-friendly message based on the ?error=<code> query the magic-link verify endpoint redirects to on failure (expired token, used token, etc).
Auth tables (no db/schema.ts or drizzle.config.ts needed for auth)
Auth tables are package-owned — apply them with npx next-starter migrate (see First-time setup below). init no longer scaffolds db/schema.ts or drizzle.config.ts for auth.
When you add your own tables, set up drizzle.config.ts and a schema file for them yourself. For an FK to the auth user, import it from the package: import { user } from "@naeemba/next-starter/schema".
First-time setup
The package owns the auth-table migrations. Apply them with:
npx next-starter migrateThat creates the user, session, account, verification, and passkey
tables and their indexes, recorded in a __next_starter_migrations journal.
Re-run after a package update whose release notes mention a schema change — it
is idempotent.
The package owns the auth tables; you do not manage them with your own
drizzle-kit. When you add your own tables, set up drizzle.config.ts +
drizzle-kit for those — a fully independent track with its own
__drizzle_migrations journal. For an FK to user, import it from the package:
import { user } from "@naeemba/next-starter/schema".
Deploy ordering
Run the package's auth migrations before start, and before a build that reads the DB during static rendering:
{
"scripts": {
"prebuild": "next-starter migrate",
"build": "next build",
"prestart": "next-starter migrate",
"start": "next start"
}
}next-starter migrate is idempotent, so steady-state deploys take a no-op hit.
The build/start container needs DATABASE_URL. If nothing on a static route
touches the DB, prestart alone is enough.
Once you add your own tables, chain your app migrate after the auth one so a FK
to user(id) resolves: "prestart": "next-starter migrate && drizzle-kit migrate".
Enabling Google sign-in
// lib/auth.ts
export const auth = await createAuth({
google: {
// clientId / clientSecret default to env GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
allowlist: (profile) => profile.email.endsWith("@acme.com"), // optional
},
})createAuth({ google }) auto-enables account linking with Google as a trusted provider (verified-email gated). Opt out with accountLinking: false.
Render the button:
<SignInForm authClient={authClient} google />Locking sign-in to one or more emails
For solo apps or admin tools, use the singleAdmin shortcut:
await createAuth({
singleAdmin: "owner@example.com", // or ["a@x.com", "b@x.com"]
google: { /* clientId/secret from env */ },
})singleAdmin auto-fills magicLink.allowlist and google.allowlist with a case-insensitive exact match. Google additionally rejects sign-in if the OAuth profile's email isn't verified. If you also pass an explicit magicLink.allowlist or google.allowlist, the explicit callback wins for that provider.
Enabling passkeys
// lib/auth.ts
export const auth = await createAuth({
passkey: { rpName: "Your App" }, // rpID and origin default from BETTER_AUTH_URL
})The passkey table is part of the package-owned auth schema — it is created by npx next-starter migrate (covered in First-time setup).
Render the sign-in button:
<SignInForm authClient={authClient} passkey />The button is hidden silently in browsers without WebAuthn support.
Add a registration page (the init CLI scaffolds this automatically when --passkey is enabled — the default):
// app/account/passkeys/page.tsx
import { PasskeyManagerPage } from "@naeemba/next-starter/pages/passkey-manager"
import { authClient } from "@/lib/auth-client"
export default function Page() {
return <PasskeyManagerPage authClient={authClient} />
}PasskeyManagerPage is the chrome-wrapped variant (heading + description + main wrapper, parallel to SignInPage). Use the lower-level PasskeyManager directly if you want to drop the "Add a passkey" button into existing settings UI.
Reading the session in a Server Component
import { requireSession } from "@/lib/auth-server"
export default async function Page() {
const { user } = await requireSession()
return <div>Signed in as {user.email}</div>
}Use getSession instead of requireSession if you want to handle the unauthenticated case yourself (it returns null rather than redirecting).
Common UX recipes
Sign out
"use client"
import { authClient } from "@/lib/auth-client"
import { useRouter } from "next/navigation"
export function SignOutButton() {
const router = useRouter()
return (
<button
type="button"
onClick={async () => {
await authClient.signOut()
router.push("/sign-in")
router.refresh() // clears server-component sessions
}}
>
Sign out
</button>
)
}authClient.signOut() clears the better-auth session cookie. router.refresh() is what tells server components to re-read the session — without it, the user appears signed in until the next navigation.
Magic-link error pages
Set errorCallbackUrl="/sign-in/error" on SignInPage. When the verify endpoint fails, better-auth redirects to that URL with ?error=<code>. Pair with <SignInErrorPage/> for friendly copy. Override codes with errorMessages:
<SignInErrorPage
errorMessages={{ EXPIRED_TOKEN: "Your link timed out. Request a new one." }}
/>Rate limits
await createAuth({
rateLimit: { window: 60, max: 5 }, // shorter window / lower max than the prod default
})Pass rateLimit: false to disable entirely, or export BETTER_AUTH_RATE_LIMIT_DISABLED=1 to force-disable for local dev (the env var is overridden by an explicit { enabled: true }).
BYO email transport
Skip the built-in Resend dispatch entirely — use your existing email wrapper:
import { sendEmail as mySendEmail } from "@/lib/email"
await createAuth({
transport: async ({ to, from, subject, text, html }) => {
await mySendEmail({ to, from, subject, text, html })
},
})The transport receives the fully rendered email (subject, html, text). RESEND_API_KEY is not needed when transport is set. allowlist still gates ahead of transport — rejected addresses never reach your function.
Custom callbackUrl query param
<SignInPage authClient={authClient} callbackParam="next" />Pair with createProxy({ callbackParam: "next" }) so the proxy → sign-in roundtrip uses the same query name end-to-end.
Protecting routes with proxy.ts
Next 16 renamed middleware.ts → proxy.ts and middleware() → proxy(). This package targets Next ≥ 16, so only the proxy form ships:
// proxy.ts (project root)
import { createProxy } from "@naeemba/next-starter/proxy"
export default createProxy({
protect: ["/admin/:path*", "/dashboard/:path*"],
signInPath: "/sign-in", // default
})
export const config = { matcher: ["/((?!_next/|favicon.ico|api/auth/).*)"] }The helper checks for the better-auth session cookie's presence — it does not validate the session against the database (that would require Node runtime; the Edge runtime can't reach Postgres). Unauthenticated requests are redirected to signInPath?callbackUrl=<original>. The real auth gate stays at the server-component level via requireSession().
Custom proxy.ts
If you already have a proxy.ts doing other work (host canonicalization, geo gating, A/B routing), import the cookie helper directly instead of wrapping createProxy:
import { type NextRequest, NextResponse } from "next/server"
import { getSessionCookie } from "@naeemba/next-starter/proxy"
export function proxy(req: NextRequest) {
if (req.nextUrl.pathname.startsWith("/admin") && !getSessionCookie(req)) {
return NextResponse.redirect(new URL("/sign-in", req.url))
}
// your other concerns ...
return NextResponse.next()
}Dev experience
If RESEND_API_KEY is unset, the magic link is written to your server logs in a line that looks like:
[magic-link-log] email=you@example.com url=http://localhost:3000/api/auth/magic-link/verify?token=...
Copy-click the URL to sign in. This is useful for local dev before you have a Resend account.
If NODE_ENV=production and RESEND_API_KEY is unset, a warning is printed at boot: magic links going to logs in prod means anyone with log access can sign in as any user.
TypeScript
This package is ESM-only with subpath exports. Your consumer tsconfig.json must set moduleResolution to "bundler" (Next 14+ default), "node16", or "nodenext". The legacy "node" resolution silently ignores subpath types conditions and imports degrade to any.
What ships in this package
| Subpath | What it is |
|---|---|
@naeemba/next-starter/auth |
createAuth() factory |
@naeemba/next-starter/client |
createAuthClient() factory |
@naeemba/next-starter/auth-route |
createAuthRoute(auth) — returns GET, POST handlers |
@naeemba/next-starter/schema |
Drizzle table definitions |
@naeemba/next-starter/db |
Lazy Drizzle client |
@naeemba/next-starter/email |
sendMagicLink({ to, url }) |
@naeemba/next-starter/pages/sign-in |
SignInForm (headless), SignInPage (with chrome), SignInErrorPage (friendly magic-link error UI). Supports google, passkey, magicLink props; reads ?callbackUrl= from the URL with open-redirect defense. |
@naeemba/next-starter/pages/passkey-manager |
PasskeyManager (button only) + PasskeyManagerPage (with chrome) — "Add a passkey" UI for settings pages |
@naeemba/next-starter/server |
createServer(auth) — returns getSession, requireSession |
@naeemba/next-starter/proxy |
createProxy Edge-safe helper for redirecting unauthenticated traffic to your sign-in page (Next 16 proxy.ts convention). Also re-exports getSessionCookie for custom proxies. |
Design and rationale
This is a versioned npm package, not a clone-and-fork template. Consumers depend on it like any other package, set env vars, and create a handful of re-export shim files (lib/auth.ts, lib/auth-client.ts, etc.) that import from the package's subpath exports. Fixes flow through a ^ bump, not a manual diff against your fork.
The re-export shim pattern is deliberate: it keeps the package's surface minimal (no client/server entry confusion at the Next.js level) while letting consumers customize per-app concerns (createAuth({ google, passkey, magicLink: { allowlist } })) in code they own.
Styling
<SignInForm/>, <SignInPage/>, <SignInErrorPage/>, <PasskeyManager/>, and <PasskeyManagerPage/> ship with minimal inline styles (plain HTML attributes) — no CSS file, no Tailwind classes, no styled-components dependency.
For one-off targeting, every component takes a className prop. For full restyling (Tailwind, shadcn, your design system), use classNames:
<SignInPage
authClient={authClient}
classNames={{
main: "min-h-screen flex items-center justify-center bg-background",
heading: "text-3xl font-bold tracking-tight",
submitButton: "btn btn-primary w-full",
googleButton: "btn btn-outline w-full",
emailInput: "input input-bordered w-full",
emailLabel: "text-sm font-medium",
error: "text-sm text-destructive mt-1",
}}
/>When a classNames.X key is set, the corresponding inline-style default is dropped for that element — your CSS becomes the single source of truth without !important. Unset keys keep the built-in defaults so you can override piecemeal.
Form keys: root, googleButton, passkeyButton, divider, dividerLine, dividerLabel, emailLabel, emailInput, submitButton, error, sentMessage. Page adds: main, heading, description.
For complete control, the shipped page is intentionally minimal — copy app/sign-in/page.tsx and call authClient.signIn.magicLink / social / passkey directly.
License
MIT — see LICENSE.