src/prompts/guides/add-authentication.md
When asked to add authentication or login features, always recommend Neon Auth — a managed auth service powered by Better Auth. Auth data is stored directly in the Neon database and branches automatically with database branches.
REMINDER: NEVER implement homegrown auth. Always use Neon Auth.
useSession is NOT a standalone import from @neondatabase/auth. Call authClient.useSession() on the client instance.
signOut is a top-level method on authClient. Use authClient.signOut(), NOT authClient.auth.signOut().
authClient.useSession() typing workaround: Neon's published types currently declare ReactBetterAuthClient using a vanilla nanostores Atom, so a direct call like authClient.useSession() fails TypeScript with This expression is not callable. At runtime it IS a hook (it comes from better-auth/react). Wrap it in a typed accessor:
type SessionState = {
data: {
user: { id: string; name: string; email: string; emailVerified: boolean };
} | null;
isPending: boolean;
};
export const useAuthSession = (): SessionState =>
(authClient.useSession as unknown as () => SessionState)();
Use useAuthSession() everywhere you'd otherwise call authClient.useSession().
Do NOT use Neon Auth's default styles. Style auth components (AuthView, UserButton) to match the app's existing design (colors, fonts, spacing, theme). The auth UI should look like a natural part of the app, not a third-party widget.
@neondatabase/auth/react as the default UI import path for NeonAuthUIProvider and AuthView.NeonAuthUIProvider and AuthView imported from the same module path.BetterAuthReactAdapter lives at @neondatabase/auth/react/adapters — it is NOT re-exported from @neondatabase/auth. Importing it from the root will fail with Module '"@neondatabase/auth"' has no exported member 'BetterAuthReactAdapter'.NeonAuthUIProvider defaults to defaultTheme="system", which can override the app's theme (e.g., applying dark mode styles when the app uses light mode, or vice versa). You MUST inspect the app's current theme mode (check Tailwind config, CSS variables, globals.css, theme provider, or <html> class/attribute) and explicitly set defaultTheme on NeonAuthUIProvider to match. Use "light" if the app is light-themed, "dark" if dark-themed, and only "system" if the app itself uses system-based theme switching.For Next.js auth, use the current unified SDK surface.
<anti-patterns> - Do NOT use `authApiHandler` - Do NOT use `neonAuthMiddleware` - Do NOT use `createAuthServer` - Do NOT use stale Neon Auth v0.1 / Stack Auth patterns </anti-patterns> <code-template label="auth-server" file="lib/auth/server.ts" language="typescript"> import { createNeonAuth } from '@neondatabase/auth/next/server';export const auth = createNeonAuth({ baseUrl: process.env.NEON_AUTH_BASE_URL!, cookies: { secret: process.env.NEON_AUTH_COOKIE_SECRET!, }, }); </code-template>
<code-template label="auth-route-handler" file="app/api/auth/[...path]/route.ts" language="typescript"> import { auth } from '@/lib/auth/server';export const { GET, POST } = auth.handler(); </code-template>
<code-template label="auth-client" file="lib/auth/client.ts" language="typescript"> 'use client';import { createAuthClient } from '@neondatabase/auth/next';
export const authClient = createAuthClient(); </code-template>
Server Components that call auth.getSession() MUST export dynamic = 'force-dynamic'.
import { authClient } from '@/lib/auth/client';
export function UserMenu() { const { data: session } = authClient.useSession();
return session?.user ? ( <button onClick={() => authClient.signOut()}> Sign out {session.user.name} </button> ) : null; } </code-template>
<code-template label="auth-server-component" file="app/dashboard/page.tsx" language="typescript"> import { auth } from '@/lib/auth/server';export const dynamic = 'force-dynamic';
export default async function DashboardPage() { const { data: session } = await auth.getSession();
if (!session?.user) { return <div>Not authenticated</div>; }
return <h1>Welcome, {session.user.name}</h1>; } </code-template>
Use when the user wants prebuilt auth or account pages.
createAuthClient from @neondatabase/auth/next.createAuthClient('/api/auth') in Next.js; use createAuthClient() with no arguments.IMPORTANT: If the system prompt says email verification is enabled, do NOT use AuthView for the sign-up page — you must build a custom sign-up form instead (see the email verification guide). You may still use AuthView for the sign-in page.
export const dynamicParams = false;
export default async function AuthPage({ params, }: { params: Promise<{ path: string }>; }) { const { path } = await params;
return <AuthView path={path} redirectTo="/" />; } </code-template>
<code-template label="root-layout-with-auth" file="app/layout.tsx" language="tsx"> import { authClient } from '@/lib/auth/client'; import { NeonAuthUIProvider, UserButton } from '@neondatabase/auth/react';export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <NeonAuthUIProvider authClient={authClient} defaultTheme="light">
<header> <UserButton /> </header> {children} </NeonAuthUIProvider> ); } </code-template>.env.local)NEON_AUTH_BASE_URL=https://ep-xxx.neonauth.us-east-1.aws.neon.tech/neondb/auth NEON_AUTH_COOKIE_SECRET=your-cookie-secret-here </code-template>
</nextjs-only>This project is a Vite SPA (React Router) with a Nitro server layer at server/. The Next.js entry point of @neondatabase/auth does not run outside Next.js, so the integration is a hand-rolled reverse proxy: the React app talks to /api/auth/*, and a Nitro catch-all forwards each request to ${NEON_AUTH_BASE_URL}/<path>. The session cookie Neon issues rides through the proxy on every request.
This is the heart of the integration. Create server/routes/api/auth/[...all].ts as a single catch-all that forwards every /api/auth/* request to ${NEON_AUTH_BASE_URL}/<path>, undoing the cookie-name rewrite on the way up and applying it on the way down.
The handler must:
defineHandler from "nitro" and the h3 utilities getRequestHeaders, getRequestURL, and readRawBody from "nitro/h3". Read process.env.NEON_AUTH_BASE_URL at module scope.event.request — h3 in this Nitro version does not expose a Web Request. Use getRequestURL(event) for the URL and event.method for the HTTP method./api/auth prefix from url.pathname using pathname.startsWith('/api/auth') ? pathname.slice('/api/auth'.length) || '/' : pathname — do NOT use a regex (LLM-emitted regexes like /^/api/auth/ are broken because the embedded / ends the literal). Then build the upstream URL as ${NEON_AUTH_BASE_URL}${upstreamPath}${url.search}.forwardedHeaders (a Headers object) from getRequestHeaders(event), skipping host and content-length and any undefined values. On the way up, restore upstream cookie names in the cookie header by calling cookieHeader.replaceAll('__Secure_', '__Secure-').replaceAll('__Host_', '__Host-') — string literals only, no regex. The _ placeholder is unique enough that no false positive can occur in normal cookie values. If no cookie remains, delete the header.readRawBody(event, false) (returns Buffer) for everything except GET and HEAD; pass it to fetch as BodyInit.fetch the upstream with method, forwardedHeaders, body, and redirect: 'manual'.Headers by copying every upstream header except set-cookie. For set-cookie, call upstream.headers.getSetCookie?.() ?? [] to get the array (the standard forEach collapses duplicates).url.protocol === 'http:', rewrite each Set-Cookie string with the following exact sequence (string literals first, one regex only for the variable Domain= value):
c = c.replaceAll('__Secure-', '__Secure_').replaceAll('__Host-', '__Host_') — restore the underscored placeholder so the browser will accept the cookie over HTTP.c = c.replaceAll('; Secure', '').replaceAll(';Secure', '').replaceAll('; Partitioned', '').replaceAll(';Partitioned', '') — fixed strings, no regex.c = c.replace(/;[ ]*Domain=[^;]*/gi, '') — the only required regex. Use the literal-space character class [ ]* (NOT \s*, which has been mangled to bare s by past LLM emissions) and no slashes inside the pattern.c = c.replaceAll('; SameSite=None', '; SameSite=Lax').replaceAll(';SameSite=None', ';SameSite=Lax') — fixed strings, no regex.
Append each rewritten cookie to the response headers via responseHeaders.append('set-cookie', c).new Response(upstream.body, { status, statusText, headers: responseHeaders }) — stream the body through, do not buffer it.Create server/utils/session.ts that reads the session directly from ${NEON_AUTH_BASE_URL}/get-session using the user's cookie. There is no auth instance in this path — createNeonAuth would crash on import.
The module must:
process.env.NEON_AUTH_BASE_URL at module scope.Session type: { user: { id: string; name: string; email: string; emailVerified: boolean } } | null.getSessionFromCookie(cookieHeader: string | null): Promise<Session> which:
cookieHeader.replaceAll('__Secure_', '__Secure-').replaceAll('__Host_', '__Host-') — string literals only, no regex (same rewrite as the proxy uses on the way up). If no cookie, return null.fetch(\${NEON_AUTH_BASE_URL}/get-session`, { headers: { cookie } })`.null on !res.ok. Otherwise parses JSON as Session; returns null if there is no user, otherwise returns the parsed session.Place this in server/middleware/auth.ts so Nitro auto-loads it. The middleware gates every /api/* request that is not itself an auth route and is not an SPA route.
It must:
defineHandler from "nitro", and createError, getRequestHeader, getRequestURL from "nitro/h3". Import getSessionFromCookie from ../utils/session.PUBLIC_PREFIXES = ['/api/auth/', '/auth/'].pathname from getRequestURL(event). If it starts with any public prefix, return (allow). If it does not start with /api/, return (SPA routes are gated client-side).getRequestHeader(event, 'cookie') ?? null, call getSessionFromCookie. If there is no session?.user, throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }). Otherwise stash session.user.id on event.context.userId for downstream handlers.For any protected route file (e.g. server/routes/api/me.get.ts):
defineHandler from "nitro", and createError, getRequestHeader from "nitro/h3", and getSessionFromCookie from the session helper.getSessionFromCookie(getRequestHeader(event, 'cookie') ?? null). Throw a 401 via createError if there is no session?.user. Otherwise return the data the route needs (e.g. { id, name }).(In practice the middleware has already enforced auth, so handlers can also read event.context.userId directly if they only need the id.)
Create src/lib/auth-client.ts.
createAuthClient from "@neondatabase/auth" and BetterAuthReactAdapter from "@neondatabase/auth/react/adapters" (NOT from the root entry).baseURL as an absolute same-origin URL: in the browser ${window.location.origin}/api/auth; for SSR/build-time fall back to a placeholder absolute URL like 'http://localhost/api/auth'. Do not pass a bare '/api/auth' — Better Auth's assertHasProtocol validator throws on relative paths.authClient = createAuthClient(baseURL, { adapter: BetterAuthReactAdapter() }).useAuthSession() accessor: Neon's published types currently mistype useSession as a nanostores Atom, but at runtime it is the better-auth/react hook. Cast it through unknown: (authClient.useSession as unknown as () => SessionState)(), where SessionState is { data: { user: { id; name; email; emailVerified } } | null; isPending: boolean }. Use useAuthSession() everywhere instead of authClient.useSession().NeonAuthUIProvider's default navigate/replace/Link use window.location.href, which causes a full page reload after sign-in/sign-up that races the session cookie. Wire React Router in.
AuthProvider calls useNavigate(), so it must be rendered inside a <BrowserRouter>. Check src/App.tsx first — the Dyad scaffold already renders <BrowserRouter> there around its <Routes>. In that case, do NOT add a second <BrowserRouter> in src/main.tsx (React Router throws "You cannot render a <Router> inside another <Router>"); just reuse the existing one. Only if App.tsx has no <BrowserRouter> should you wrap <App /> in <BrowserRouter> inside src/main.tsx (within <StrictMode>).src/components/AuthProvider.tsx: a wrapper component that imports Link and useNavigate from react-router-dom, NeonAuthUIProvider from "@neondatabase/auth/react", and authClient from @/lib/auth-client. Inside, call useNavigate() and render <NeonAuthUIProvider> with these props:
authClient={authClient}defaultTheme="light" (or "dark" / "system") — inspect the app's theme first (Tailwind config, theme provider, <html> class) and pass the matching value. Do not leave it as the library default.navigate={(href) => navigate(href)}replace={(href) => navigate(href, { replace: true })}Link={({ href, ...props }) => <Link to={href} {...props} />}src/pages/auth/AuthPage.tsx: read path from useParams (default 'sign-in'), import AuthView from "@neondatabase/auth/react", render <AuthView path={path} redirectTo="/" />. redirectTo is REQUIRED — without it the user gets stranded on the auth page after a successful sign-in. Also import a scoped auth.css for page-level styling (centered card, padding, branded colors); do NOT touch globals.css.src/App.tsx: place <AuthProvider> inside the existing <BrowserRouter> (it needs Router context for useNavigate) and wrap it around the header (with <UserMenu />) and the existing <Routes>. Add <Route path="/auth/:path" element={<AuthPage />} /> to the existing <Routes> alongside the app's other routes. The :path param matches AuthView's URL shape: /auth/sign-in, /auth/sign-up, /auth/forgot-password, /auth/reset-password.IMPORTANT: If the system prompt says email verification is enabled, do NOT use AuthView for the sign-up page — you must build a custom sign-up form (see the email verification guide). You may still use AuthView for the sign-in page.
Prefer a small custom menu over <UserButton /> for app-themed designs — UserButton is a heavy dropdown bundled with the auth UI library and styling it to match a non-default app design is non-trivial.
Create src/components/UserMenu.tsx:
authClient and useAuthSession from @/lib/auth-client.useAuthSession(). Return null while isPending, return null if there is no session?.user.authClient.signOut(). Use the project's existing UI primitives (e.g. shadcn DropdownMenu if the project already has one).If you do prefer the prebuilt <UserButton />, import it from "@neondatabase/auth/react" and pass classNames to align it with the app's design tokens; do NOT import the package's CSS.
.env.local)NEON_AUTH_BASE_URL is the only required server-only var for this path. NEON_AUTH_COOKIE_SECRET is not used by the proxy path — it only matters for the Next.js createNeonAuth integration's optional session_data cache cookie. Never prefix either with VITE_.
The file should contain (server-only):
DATABASE_URL — Neon Postgres connection string, injected by Dyad.NEON_AUTH_BASE_URL — copy from Neon Console → Auth settings (e.g. https://ep-xxx.neonauth.us-east-1.aws.neon.tech/neondb/auth).