New in Neon: Vector and BM25 full-text search extensions built for lakebase architecture, plus major CLI updates. See what shipped this week.
/Build a full backend

Build a full backend with Next.js and Neon

Connect Postgres with Drizzle, add managed authentication, and ship a typed server-side backend

Before you start

You'll need Node.js 20+ installed.

  1. 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.

  2. 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.

    Neon Auth Base URL

  3. Scaffold a Next.js app

    Create a new Next.js project with TypeScript, Tailwind CSS, and the App Router. The --yes flag accepts the remaining defaults without prompting.

    Terminal
    npx create-next-app@latest my-backend --typescript --tailwind --app --eslint --yes
    cd my-backend
  4. Install dependencies and add environment variables

    Install three packages: @neondatabase/neon-js for auth, drizzle-orm for typed queries, and @neondatabase/serverless for the HTTP driver (works in Node, edge, and serverless runtimes). Add drizzle-kit as a dev dependency for the schema migration.

    Then create .env.local with 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.

    Terminal
    npm install @neondatabase/neon-js drizzle-orm @neondatabase/serverless
    npm install -D drizzle-kit
    .env.local
    DATABASE_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-secret
  5. Define the Drizzle schema

    Create a TypeScript schema for a posts table. Drizzle uses this for both the migration and your type-safe queries.

    lib/db/schema.ts
    import { 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.ts
    import { 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-kit is a standalone CLI and doesn't read .env.local automatically. loadEnvConfig matches Next.js's env loading behavior so the migration step picks up the same DATABASE_URL as the app.

  6. Push the schema and seed sample data

    drizzle-kit push creates the table directly from your schema. In production, you'd typically use drizzle-kit generate and drizzle-kit migrate for 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.

    Terminal
    npx drizzle-kit push

    Open 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);
  7. 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.ts
    import { 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.ts
    import { auth } from '@/lib/auth/server';
    
    export const { GET, POST } = auth.handler();
    proxy.ts
    import { auth } from '@/lib/auth/server';
    
    export default auth.middleware({
      loginUrl: '/auth/sign-in',
    });
    
    export const config = {
      matcher: ['/posts/:path*'],
    };

    Next.js version compatibility

    proxy.ts replaces middleware.ts in Next.js 16. On earlier versions, name the file middleware.ts and export default function middleware instead of proxy. The auth logic is identical.

  8. 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() or auth.signIn.email() on the server, then redirects to /posts on success or returns an error string for the form to display.

    No layout or provider component is needed. The scaffold's default app/layout.tsx is 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');
    }
  9. Query Postgres from a Server Component

    Create the Drizzle client and a protected /posts page. 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, and dynamic = 'force-dynamic' keeps the data fresh on every request.

    lib/db/client.ts
    import { 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.tsx
    import { 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>
      );
    }
  10. 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 /posts where the two published posts appear above your signed-in name.

    If you visit /posts without signing in, the middleware redirects you to /auth/sign-in.

    Terminal
    npm 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() and auth.signIn.email()
  • The /posts route 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 push to drizzle-kit generate for 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.

Was this page helpful?
Edit on GitHub