Before you start
You'll need Node.js 20+ installed.
Create a Neon project
If you don't have a Neon account, sign up at console.neon.tech.
Pick a path to create the project, then copy the connection string. You'll add it to your environment in step 4.
In the Neon Console, click New Project, name it
my-backend, and create it. From the project dashboard, click Connect and copy the connection string.Enable Neon Auth
beta Neon Auth with Better Auth is in beta. Share feedback on Discord.
Enable Auth on your project's default branch and copy the Auth URL. You'll add it to your environment in step 4.
In the project sidebar, go to Auth and click Enable Auth. On the Configuration tab, copy your Auth URL.

Scaffold a Next.js app
Create a new Next.js project with TypeScript, Tailwind CSS, and the App Router. The
--yesflag accepts the remaining defaults without prompting.Terminalnpx create-next-app@latest my-backend --typescript --tailwind --app --eslint --yes cd my-backendInstall dependencies and add environment variables
Install three packages:
@neondatabase/neon-jsfor auth,drizzle-ormfor typed queries, and@neondatabase/serverlessfor the HTTP driver (works in Node, edge, and serverless runtimes). Adddrizzle-kitas a dev dependency for the schema migration.Then create
.env.localwith your connection string, Auth URL, and a generated cookie secret.Generate the cookie secret with
openssl rand -base64 32. It must be at least 32 characters.Terminalnpm install @neondatabase/neon-js drizzle-orm @neondatabase/serverless npm install -D drizzle-kit.env.localDATABASE_URL=postgresql://... NEON_AUTH_BASE_URL=https://ep-xxx.neonauth.c-7.us-east-1.aws.neon.tech/neondb/auth NEON_AUTH_COOKIE_SECRET=replace-with-32-char-random-secretDefine the Drizzle schema
Create a TypeScript schema for a
poststable. Drizzle uses this for both the migration and your type-safe queries.lib/db/schema.tsimport { bigint, boolean, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; export const posts = pgTable('posts', { id: bigint('id', { mode: 'number' }) .primaryKey() .generatedByDefaultAsIdentity(), userId: text('user_id').notNull(), content: text('content').notNull(), isPublished: boolean('is_published').notNull().default(false), createdAt: timestamp('created_at', { withTimezone: true }) .notNull() .defaultNow(), });drizzle.config.tsimport { loadEnvConfig } from '@next/env'; import { defineConfig } from 'drizzle-kit'; loadEnvConfig(process.cwd()); export default defineConfig({ schema: './lib/db/schema.ts', dialect: 'postgresql', dbCredentials: { url: process.env.DATABASE_URL!, }, });drizzle-kitis a standalone CLI and doesn't read.env.localautomatically.loadEnvConfigmatches Next.js's env loading behavior so the migration step picks up the sameDATABASE_URLas the app.Push the schema and seed sample data
drizzle-kit pushcreates the table directly from your schema. In production, you'd typically usedrizzle-kit generateanddrizzle-kit migratefor tracked migrations, but push is faster for a tutorial.Then seed three sample posts in the Neon Console SQL Editor — two published and one draft, so step 9's
where(eq(posts.isPublished, true))filter has something visible to do.Terminalnpx drizzle-kit pushOpen your project in the Neon Console, go to SQL Editor, and run:
INSERT INTO posts (user_id, content, is_published) VALUES ('00000000-0000-0000-0000-000000000000', 'Hello from Neon', true), ('00000000-0000-0000-0000-000000000000', 'Welcome to your new backend', true), ('00000000-0000-0000-0000-000000000000', 'This draft is hidden — flip is_published to true in the SQL editor to see it appear', false);Wire up auth
Add four files. The server instance handles auth on the server side. The client exposes auth methods to the browser. The API route proxies sign-up, sign-in, and OAuth callbacks. The middleware redirects unauthenticated users to the sign-in page.
lib/auth/server.tsimport { createNeonAuth } from '@neondatabase/neon-js/auth/next/server'; export const auth = createNeonAuth({ baseUrl: process.env.NEON_AUTH_BASE_URL!, cookies: { secret: process.env.NEON_AUTH_COOKIE_SECRET!, }, });lib/auth/client.ts'use client'; import { createAuthClient } from '@neondatabase/neon-js/auth/next'; export const authClient = createAuthClient();app/api/auth/[...path]/route.tsimport { auth } from '@/lib/auth/server'; export const { GET, POST } = auth.handler();proxy.tsimport { auth } from '@/lib/auth/server'; export default auth.middleware({ loginUrl: '/auth/sign-in', }); export const config = { matcher: ['/posts/:path*'], };Next.js version compatibility
proxy.tsreplacesmiddleware.tsin Next.js 16. On earlier versions, name the filemiddleware.tsand exportdefault function middlewareinstead ofproxy. The auth logic is identical.Build the sign-in and sign-up pages
Each page is a client form that posts to a server action. The action calls
auth.signUp.email()orauth.signIn.email()on the server, then redirects to/postson success or returns an error string for the form to display.No layout or provider component is needed. The scaffold's default
app/layout.tsxis all the wrapper you need.app/auth/sign-up/page.tsx'use client'; import { useActionState } from 'react'; import { signUpWithEmail } from './actions'; export default function SignUpForm() { const [state, formAction, isPending] = useActionState(signUpWithEmail, null); return ( <form action={formAction} className="flex min-h-screen flex-col items-center justify-center gap-5 bg-gray-900" > <h1 className="text-2xl font-bold text-white">Create new account</h1> <label className="flex w-sm flex-col gap-1.5"> <span className="text-sm font-medium text-gray-100">Name</span> <input name="name" type="text" required className="rounded-md bg-white/5 px-2 py-1.5 text-white outline-1 outline-white/10" /> </label> <label className="flex w-sm flex-col gap-1.5"> <span className="text-sm font-medium text-gray-100">Email</span> <input name="email" type="email" required className="rounded-md bg-white/5 px-2 py-1.5 text-white outline-1 outline-white/10" /> </label> <label className="flex w-sm flex-col gap-1.5"> <span className="text-sm font-medium text-gray-100">Password</span> <input name="password" type="password" required className="rounded-md bg-white/5 px-2 py-1.5 text-white outline-1 outline-white/10" /> </label> {state?.error && <p className="text-sm text-red-500">{state.error}</p>} <button type="submit" disabled={isPending} className="w-sm rounded-md bg-indigo-500 px-3 py-1.5 text-sm font-semibold text-white hover:bg-indigo-400"> {isPending ? 'Creating account...' : 'Create account'} </button> </form> ); }app/auth/sign-up/actions.ts'use server'; import { auth } from '@/lib/auth/server'; import { redirect } from 'next/navigation'; export async function signUpWithEmail( _prev: { error: string } | null, formData: FormData, ) { const { error } = await auth.signUp.email({ name: formData.get('name') as string, email: formData.get('email') as string, password: formData.get('password') as string, }); if (error) return { error: error.message || 'Failed to create account' }; redirect('/posts'); }app/auth/sign-in/page.tsx'use client'; import { useActionState } from 'react'; import { signInWithEmail } from './actions'; export default function SignInForm() { const [state, formAction, isPending] = useActionState(signInWithEmail, null); return ( <form action={formAction} className="flex min-h-screen flex-col items-center justify-center gap-5 bg-gray-900" > <h1 className="text-2xl font-bold text-white">Sign in to your account</h1> <label className="flex w-sm flex-col gap-1.5"> <span className="text-sm font-medium text-gray-100">Email</span> <input name="email" type="email" required className="rounded-md bg-white/5 px-2 py-1.5 text-white outline-1 outline-white/10" /> </label> <label className="flex w-sm flex-col gap-1.5"> <span className="text-sm font-medium text-gray-100">Password</span> <input name="password" type="password" required className="rounded-md bg-white/5 px-2 py-1.5 text-white outline-1 outline-white/10" /> </label> {state?.error && <p className="text-sm text-red-500">{state.error}</p>} <button type="submit" disabled={isPending} className="w-sm rounded-md bg-indigo-500 px-3 py-1.5 text-sm font-semibold text-white hover:bg-indigo-400"> {isPending ? 'Signing in...' : 'Sign in'} </button> </form> ); }app/auth/sign-in/actions.ts'use server'; import { auth } from '@/lib/auth/server'; import { redirect } from 'next/navigation'; export async function signInWithEmail( _prev: { error: string } | null, formData: FormData, ) { const { error } = await auth.signIn.email({ email: formData.get('email') as string, password: formData.get('password') as string, }); if (error) return { error: error.message || 'Failed to sign in' }; redirect('/posts'); }Query Postgres from a Server Component
Create the Drizzle client and a protected
/postspage. The page is a Server Component, so both the session lookup and the Drizzle query run on the server at request time.auth.getSession()reads the signed-in user from cookies, Drizzle returns typed query results, anddynamic = 'force-dynamic'keeps the data fresh on every request.lib/db/client.tsimport { drizzle } from 'drizzle-orm/neon-http'; import { neon } from '@neondatabase/serverless'; import * as schema from './schema'; const sql = neon(process.env.DATABASE_URL!); export const db = drizzle(sql, { schema });app/posts/page.tsximport { auth } from '@/lib/auth/server'; import { db } from '@/lib/db/client'; import { posts } from '@/lib/db/schema'; import { desc, eq } from 'drizzle-orm'; export const dynamic = 'force-dynamic'; export default async function PostsPage() { const { data: session } = await auth.getSession(); const allPosts = await db .select() .from(posts) .where(eq(posts.isPublished, true)) .orderBy(desc(posts.createdAt)) .limit(10); return ( <main className="p-8"> <h1 className="mb-1 text-2xl font-bold">Published posts</h1> {session?.user && ( <p className="mb-4 text-sm text-gray-600"> Signed in as <span className="font-medium">{session.user.name}</span> </p> )} <ul className="space-y-2"> {allPosts.map((post) => ( <li key={post.id} className="rounded border p-3"> {post.content} </li> ))} </ul> </main> ); }Run the app
Start the dev server, then open http://localhost:3000/auth/sign-up. Create a test user, and you'll be redirected to
/postswhere the two published posts appear above your signed-in name.If you visit
/postswithout signing in, the middleware redirects you to/auth/sign-in.Terminalnpm run dev
What you built
You now have a Next.js app where:
- Sign-up and sign-in are handled by Neon Auth via server actions that call
auth.signUp.email()andauth.signIn.email() - The
/postsroute is protected by middleware - The signed-in user's name is read from cookies via
auth.getSession()on the same page - Published posts are queried server-side via Drizzle with full TypeScript types
- The application can be deployed to any Next.js App Router host that supports server actions, including Vercel, Netlify, and self-hosted Node
Next steps
- Write data with Server Actions (Drizzle insert reference): wire up post creation through a server action that uses the auth session for
user_id - Branch for previews: branching authentication gives every preview environment its own user state
- Optimize for the edge: on Vercel or Cloudflare, configure connection pooling for production
- Generated migrations: switch from
drizzle-kit pushtodrizzle-kit generatefor tracked schema changes
Need help?
Join our Discord Server to ask questions or see what others are doing with Neon. For paid plan support options, see Support.








