AnyLearn
All lessons
Programmingintermediate

Full-Stack Next.js with Prisma and Postgres

Ship a full-stack app on Next.js 15: Prisma schema, migrations vs db push, the hot-reload-safe client singleton, Server Components reading data, Server Actions mutating it, useOptimistic, Auth.js, and a Vercel + Neon deploy.

Not signed in — your progress and quiz score won't be saved.
Lesson progress1 / 10

The stack and the why

Next.js 15 (App Router, React 19) for the framework, Prisma 6 as the type-safe DB client, Postgres as the DB, Neon (or any Postgres) for hosting, Vercel for deploys, Auth.js v5 (NextAuth) for sessions. This combo lets one developer ship a real product in a weekend and lets a team scale it without rewriting.

The shape we're building toward: Server Components read Postgres directly, Server Actions write to Postgres, the client only re-renders to reflect changes — no useEffect fetches, no separate API layer. The browser bundle shrinks; the code is roughly half the volume of an Express + React + axios + Redux app for the same features.

Full lesson text

All 10 steps on one page — for reading, reference, and search.

Show

1. The stack and the why

Next.js 15 (App Router, React 19) for the framework, Prisma 6 as the type-safe DB client, Postgres as the DB, Neon (or any Postgres) for hosting, Vercel for deploys, Auth.js v5 (NextAuth) for sessions. This combo lets one developer ship a real product in a weekend and lets a team scale it without rewriting.

The shape we're building toward: Server Components read Postgres directly, Server Actions write to Postgres, the client only re-renders to reflect changes — no useEffect fetches, no separate API layer. The browser bundle shrinks; the code is roughly half the volume of an Express + React + axios + Redux app for the same features.

2. Prisma schema

The schema is the source of truth. Run pnpm dlx prisma init then edit prisma/schema.prisma:

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model Todo {
  id        String   @id @default(cuid())
  title     String
  done      Boolean  @default(false)
  userId    String
  createdAt DateTime @default(now())
  @@index([userId, createdAt])
}

pnpm prisma generate writes a fully typed client into node_modules/@prisma/client. Now db.todo.findMany({ where: { userId } }) is typed end-to-end — return type, filter shape, the lot.

3. Migrations vs db push

Two ways to apply a schema change:

  • prisma db push — sync the DB to the schema without recording a migration. Great for prototyping and for branch dbs.
  • prisma migrate dev --name add_todo_done — generates a versioned SQL migration in prisma/migrations/, applies it, and updates the migration history table.

In production you always want migrations. They're committed to git, code-reviewable, and replayable: prisma migrate deploy in CI applies any pending migrations against the production DB in order. Use db push for local hacking, switch to migrations the moment more than one person touches the schema.

4. The Prisma client singleton (hot-reload gotcha)

Next.js dev mode re-evaluates your module graph on every change. Naively new PrismaClient() per import leaks connections until your DB rejects new ones. Use this singleton pattern:

// lib/db.ts
import { PrismaClient } from '@prisma/client';

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };

export const db =
  globalForPrisma.prisma ??
  new PrismaClient({ log: ['error', 'warn'] });

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;

In production each instance gets one client; in dev the global stashes it across HMR reloads. This is the #1 "why am I out of DB connections?" cause for Next.js newcomers.

5. Server Component reading data

With the singleton in place, a Server Component reads Postgres in one line:

// app/todos/page.tsx
import { db } from '@/lib/db';
import { auth } from '@/auth';
import { TodoList } from './todo-list';

export default async function TodosPage() {
  const session = await auth();
  if (!session) return <p>Sign in.</p>;
  const todos = await db.todo.findMany({
    where: { userId: session.user.id },
    orderBy: { createdAt: 'desc' },
  });
  return <TodoList initial={todos} />;
}

No loading state, no fetcher, no API route, no useQuery. The page awaits the DB on the server, streams HTML to the browser, and hands a typed array to a Client Component for interactivity.

6. Server Action mutating data

Mutations live in 'use server' files. They run on the server, get full DB access, and revalidate the relevant route afterward.

// app/todos/actions.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
import { auth } from '@/auth';

const Create = z.object({ title: z.string().min(1).max(120) });

export async function addTodo(formData: FormData) {
  const session = await auth();
  if (!session) throw new Error('unauthorized');
  const { title } = Create.parse({ title: formData.get('title') });
  await db.todo.create({ data: { title, userId: session.user.id } });
  revalidatePath('/todos');
}

export async function toggleTodo(id: string) {
  const t = await db.todo.findUnique({ where: { id } });
  if (!t) return;
  await db.todo.update({ where: { id }, data: { done: !t.done } });
  revalidatePath('/todos');
}

That's the entire write side of a CRUD feature. No Express, no fetch, no JSON marshalling.

7. Optimistic UI with useOptimistic

Server Actions feel slower than they are because the user waits for the round-trip. useOptimistic lets you paint the new state immediately and reconcile when the server confirms.

'use client';
import { useOptimistic } from 'react';
import { addTodo } from './actions';

export function TodoList({ initial }: { initial: Todo[] }) {
  const [todos, addOptimistic] = useOptimistic(
    initial,
    (state, next: Todo) => [next, ...state],
  );
  return (
    <form action={async (fd) => {
      addOptimistic({ id: 'temp', title: String(fd.get('title')), done: false } as Todo);
      await addTodo(fd);
    }}>
      <input name="title" /> <button>add</button>
      <ul>{todos.map(t => <li key={t.id}>{t.title}</li>)}</ul>
    </form>
  );
}

The temp row paints immediately. When addTodo finishes and Next revalidates /todos, the server re-renders the list and the temp row is replaced by the real one — same data, same key shape, no flicker.

8. Full data flow

From a user click to a DB write and back: every hop is typed, server-rendered where possible, with a Server Action closing the write loop.

sequenceDiagram
  participant U as User (Browser)
  participant C as Client Component
  participant S as Server Action
  participant P as Prisma
  participant DB as Postgres
  U->>C: click 'Add'
  C->>C: useOptimistic appends row
  C->>S: addTodo(formData)
  S->>P: db.todo.create
  P->>DB: INSERT INTO todo
  DB-->>P: row id
  P-->>S: typed result
  S->>S: revalidatePath('/todos')
  S-->>C: action resolves
  C-->>U: reconciled list (server-rendered HTML)

9. Auth.js v5 briefly

For session-based auth, Auth.js (NextAuth) v5 is the path of least resistance. Install, configure a provider, and you get an auth() helper you can call from Server Components and Server Actions.

// auth.ts
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { db } from '@/lib/db';

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(db),
  providers: [Google],
});

Add the Auth.js models to schema.prisma, expose its route handler at app/api/auth/[...nextauth]/route.ts, and await auth() works anywhere on the server. For end-to-end ownership of auth you might pick Clerk or build your own, but for most teams Auth.js is enough and free.

10. Deploying to Vercel + Neon

Provision a Neon Postgres branch and grab the pooled connection string. In Vercel project settings:

DATABASE_URL=postgresql://user:pw@host/db?sslmode=require
DIRECT_URL=postgresql://user:pw@host/db?sslmode=require   # non-pooled, for migrations

Update schema.prisma:

datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")
  directUrl = env("DIRECT_URL")
}

In package.json build script: prisma migrate deploy && next build. Push to GitHub, connect the repo in Vercel, click deploy. Neon's branching means every preview deploy can get its own DB branch wired up via the Vercel-Neon integration — your reviewers get a real, isolated environment per PR. That's the whole 2026 full-stack workflow.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. Why do Next.js apps that 'just `new PrismaClient()` once per import' eventually exhaust DB connections in dev?
    • HMR re-evaluates modules, instantiating a new client each time; stash it on globalThis to reuse across reloads.
    • Prisma never closes idle connections in development.
    • Next.js forks a new Node process per route.
    • Postgres caps connections at 1 unless TLS is disabled.
  2. Which command should you wire into your production CI/CD step before `next build`?
    • prisma migrate deploy
    • prisma db push --force-reset
    • prisma generate --watch
    • prisma studio --headless
  3. After a Server Action creates a Todo, why does revalidatePath('/todos') matter?
    • It invalidates the cached Server Component output so the next render reads fresh data from the DB.
    • It re-issues the user's session cookie.
    • It runs prisma generate to refresh client types.
    • It is required by Postgres to commit the transaction.
  4. What problem does useOptimistic solve here?
    • It paints the predicted post-mutation UI immediately so the user doesn't wait for the Server Action round-trip.
    • It runs the mutation twice for redundancy.
    • It debounces rapid clicks on the submit button.
    • It synchronizes Prisma transactions across tabs.
  5. Why use both `url` (pooled) and `directUrl` (non-pooled) in the Prisma datasource block on Neon?
    • Runtime queries use the pooled connection; migrations need a direct, non-pooled connection.
    • Neon requires two separate accounts per project.
    • Prisma reads from `url` and writes to `directUrl`.
    • directUrl is only for testing; it's ignored in production.

Related lessons