}

Next.js 15 Full-Stack App 2026: App Router, Prisma, NextAuth, and Vercel

Next.js 15 is the most capable version of the framework to date. Turbopack ships stable, React 19 is the default, and the caching model has been redesigned from the ground up to be explicit rather than implicit. If you have been waiting for the right moment to build a production full-stack application on the App Router, that moment is now.

This tutorial walks you through every layer of the stack: routing, data fetching with React Server Components, form handling with Server Actions, a Postgres database with Prisma ORM, authentication with NextAuth.js v5, and a final deployment to Vercel. Every code sample is TypeScript.


TL;DR

What you will build: A full-stack web application with protected routes, a Postgres-backed data layer, and GitHub + email/password authentication, deployed to Vercel.

Key technologies: Next.js 15, React 19, TypeScript, Tailwind CSS, Prisma ORM, NextAuth.js v5, Vercel Postgres (or any Postgres provider).

Prerequisite knowledge: Familiarity with React hooks, async/await, and basic SQL. You do not need prior Next.js experience.

Time to complete: 2-3 hours end to end.

Quick-start command: bash npx create-next-app@latest my-app --typescript --tailwind --app --src-dir


1. What Is New in Next.js 15

Turbopack Is Now Stable

After two years in beta, Turbopack — the Rust-based bundler that replaces Webpack — reached stable status with Next.js 15. The headline numbers are compelling: local server start is up to 76% faster and hot module replacement (HMR) is up to 96% faster compared to Webpack. You opt in by passing --turbopack to the dev command:

next dev --turbopack

Production builds still use Webpack by default in 15.x, but a Turbopack production mode is available behind a flag and expected to graduate in a future minor release.

Explicit Caching by Default

Next.js 13 and 14 cached fetch() responses aggressively by default, which surprised many developers when they saw stale data. Next.js 15 flips that default: fetch() requests, GET Route Handlers, and the client-side router cache are no longer cached by default. You must opt in explicitly with { cache: 'force-cache' } or a revalidate option.

This is a breaking change from Next.js 14. When migrating, audit every fetch() call that relied on the old implicit caching behaviour.

React 19 Support

React 19 ships as the default peer dependency. The most impactful additions for Next.js developers are:

  • use() hook — unwrap promises and context inside components without async/await at the component level.
  • Form actions — native <form action={serverAction}> syntax without a JavaScript wrapper.
  • Improved hydration error messages — the browser console now shows the exact diffing mismatch, which drastically reduces debugging time.
  • useOptimistic — built-in optimistic state updates for mutations.

after() API

after() lets you schedule work to run after a response has been sent to the client. This is ideal for analytics logging, audit trails, and non-critical side effects that should not block the user-facing response:

import { after } from 'next/server';

export async function POST(request: Request) {
  const data = await request.json();
  const result = await saveRecord(data);

  after(async () => {
    await logAuditEvent({ action: 'create', recordId: result.id });
  });

  return Response.json(result);
}

next/form Component

A new <Form> component from next/form extends the native HTML form element with client-side navigation on submission, prefetching of the target route, and loading state management — all without extra JavaScript on your part.


2. App Router vs Pages Router: When to Migrate

The App Router (inside app/) and the Pages Router (inside pages/) coexist in the same project. You do not have to migrate everything at once.

ConcernPages RouterApp Router
Default component typeClient ComponentServer Component
Data fetchinggetServerSideProps, getStaticPropsasync component + fetch()
LayoutsCustom _app.tsxNested layout.tsx files
StreamingNot supportedBuilt in via <Suspense>
Server ActionsNot availableNative support
MiddlewareSupportedSupported

Migrate when: You are starting a new project (use App Router from day one), or you need streaming, Server Actions, or fine-grained server/client component control.

Stay on Pages Router when: Your application is large and stable, your team is not yet familiar with the mental model shift, or you have heavy investment in getServerSideProps logic that is not worth rewriting yet.

Both routers will be supported long-term. There is no deprecation timeline for the Pages Router.


3. Project Setup

Bootstrap a new project with all the recommended options:

npx create-next-app@latest my-app \
  --typescript \
  --tailwind \
  --app \
  --src-dir \
  --import-alias "@/*"

cd my-app

The flags:

  • --typescript — TypeScript configuration and type definitions included.
  • --tailwind — Tailwind CSS v4 configured with PostCSS.
  • --app — scaffold the app/ directory instead of pages/.
  • --src-dir — place source files under src/ for cleaner organisation.
  • --import-alias "@/*" — map @/ to src/ for absolute imports.

Install additional dependencies up front:

npm install prisma @prisma/client next-auth@beta zod
npm install --save-dev @types/node
npx prisma init

Your src/ directory structure will look like this after the tutorial:

src/
  app/
    (auth)/
      login/
        page.tsx
      register/
        page.tsx
      layout.tsx
    (dashboard)/
      dashboard/
        page.tsx
        loading.tsx
        error.tsx
      layout.tsx
    api/
      auth/
        [...nextauth]/
          route.ts
    layout.tsx
    page.tsx
  components/
    ui/
    forms/
  lib/
    auth.ts
    db.ts
    validations.ts
  middleware.ts

4. File-Based Routing in the App Router

Every folder inside app/ maps to a URL segment. Special file names control rendering behaviour.

layout.tsx

A layout wraps its children and persists across navigations within its segment. The root layout is mandatory and must include <html> and <body>.

// src/app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: { template: '%s | My App', default: 'My App' },
  description: 'A full-stack Next.js 15 application.',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>{children}</body>
    </html>
  );
}

page.tsx

A page.tsx file makes a route publicly accessible. The file exports a default React component.

// src/app/page.tsx
export default function HomePage() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-center p-24">
      <h1 className="text-4xl font-bold">Welcome</h1>
    </main>
  );
}

loading.tsx

loading.tsx exports a component that is shown automatically while a page or its data is loading. Next.js wraps the page in a <Suspense> boundary behind the scenes.

// src/app/(dashboard)/dashboard/loading.tsx
export default function DashboardLoading() {
  return (
    <div className="flex items-center justify-center h-64">
      <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
    </div>
  );
}

error.tsx

error.tsx catches runtime errors inside its segment and displays a fallback UI. It must be a Client Component because it uses the reset callback.

// src/app/(dashboard)/dashboard/error.tsx
'use client';

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="p-6 rounded-md bg-red-50 border border-red-200">
      <h2 className="text-lg font-semibold text-red-700">Something went wrong</h2>
      <p className="text-sm text-red-600 mt-1">{error.message}</p>
      <button
        onClick={reset}
        className="mt-4 px-4 py-2 text-sm bg-red-600 text-white rounded-md hover:bg-red-700"
      >
        Try again
      </button>
    </div>
  );
}

Route Groups

Wrapping a folder name in parentheses — (auth), (dashboard) — creates a route group. The group name is excluded from the URL. Route groups let you apply different layouts to different sections of your app without affecting the URL structure.


5. React Server Components vs Client Components

This is the most important conceptual shift in the App Router.

Server Components (the default) render exclusively on the server. They can be async, access databases and file systems directly, and never send their component code to the browser. They cannot use hooks, browser APIs, or event handlers.

Client Components are marked with 'use client' at the top of the file. They render on the server for the initial HTML (SSR) and then hydrate in the browser. They can use all React hooks and browser APIs.

The Decision Tree

Does this component need:
  - onClick, onChange, or other event handlers? → Client Component
  - useState, useEffect, useReducer?            → Client Component
  - Browser APIs (localStorage, window)?        → Client Component
  - Access to database / secrets / file system? → Server Component
  - Large dependency that should not ship to browser? → Server Component
  - Everything else?                            → Server Component (prefer this)

Composing the Two

A Server Component can import and render a Client Component. A Client Component cannot import a Server Component (but it can receive one as a children prop).

// src/app/(dashboard)/dashboard/page.tsx  — Server Component
import { db } from '@/lib/db';
import { StatsCard } from '@/components/ui/StatsCard'; // Client Component

export default async function DashboardPage() {
  const stats = await db.post.aggregate({ _count: true });

  return (
    <div className="grid grid-cols-3 gap-4">
      <StatsCard label="Total posts" value={stats._count} />
    </div>
  );
}
// src/components/ui/StatsCard.tsx  — Client Component
'use client';

import { useState } from 'react';

export function StatsCard({ label, value }: { label: string; value: number }) {
  const [hovered, setHovered] = useState(false);

  return (
    <div
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={() => setHovered(false)}
      className={`p-4 rounded-lg border ${hovered ? 'shadow-md' : ''}`}
    >
      <p className="text-sm text-gray-500">{label}</p>
      <p className="text-2xl font-bold">{value}</p>
    </div>
  );
}

6. Data Fetching Patterns

Fetching in Server Components

The simplest pattern is an async Server Component that awaits a database query or fetch() call directly.

// src/app/(dashboard)/dashboard/page.tsx
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
  const session = await auth();
  if (!session) redirect('/login');

  const posts = await db.post.findMany({
    where: { authorId: session.user.id },
    orderBy: { createdAt: 'desc' },
    take: 10,
  });

  return (
    <ul className="divide-y">
      {posts.map((post) => (
        <li key={post.id} className="py-3">
          <p className="font-medium">{post.title}</p>
          <time className="text-xs text-gray-400">
            {post.createdAt.toLocaleDateString()}
          </time>
        </li>
      ))}
    </ul>
  );
}

Revalidation

Because caching is now opt-in, you explicitly control freshness. Three patterns cover most use cases:

// 1. Time-based revalidation — re-fetch at most every 60 seconds
const res = await fetch('https://api.example.com/data', {
  next: { revalidate: 60 },
});

// 2. On-demand revalidation via tag — call revalidateTag('posts') in a Server Action
const res = await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] },
});

// 3. No cache — always fetch fresh (the new default in Next.js 15)
const res = await fetch('https://api.example.com/live-data', {
  cache: 'no-store',
});

For Prisma queries (which do not go through fetch()), use revalidatePath or revalidateTag inside Server Actions after mutations.

Parallel Data Fetching

Avoid waterfalls by starting multiple independent queries at the same time:

export default async function ProfilePage({ params }: { params: { id: string } }) {
  const [user, posts, followers] = await Promise.all([
    db.user.findUnique({ where: { id: params.id } }),
    db.post.findMany({ where: { authorId: params.id } }),
    db.follow.count({ where: { followingId: params.id } }),
  ]);

  // render with all three...
}

7. Server Actions

Server Actions are async functions that run on the server and can be called from a Client Component or a <form>. They eliminate the need for a dedicated API route for most mutations.

Creating a Server Action

// src/lib/actions/posts.ts
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';

const CreatePostSchema = z.object({
  title: z.string().min(3).max(120),
  content: z.string().min(10),
});

export async function createPost(formData: FormData) {
  const session = await auth();
  if (!session) throw new Error('Unauthenticated');

  const parsed = CreatePostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  });

  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }

  await db.post.create({
    data: {
      title: parsed.data.title,
      content: parsed.data.content,
      authorId: session.user.id,
    },
  });

  revalidatePath('/dashboard');
  redirect('/dashboard');
}

Using a Server Action in a Form

// src/components/forms/CreatePostForm.tsx
'use client';

import { useActionState } from 'react';
import { createPost } from '@/lib/actions/posts';

export function CreatePostForm() {
  const [state, formAction, isPending] = useActionState(createPost, null);

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="title" className="block text-sm font-medium">
          Title
        </label>
        <input
          id="title"
          name="title"
          type="text"
          required
          className="mt-1 block w-full rounded-md border px-3 py-2 text-sm"
        />
        {state?.error?.title && (
          <p className="text-xs text-red-500 mt-1">{state.error.title[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="content" className="block text-sm font-medium">
          Content
        </label>
        <textarea
          id="content"
          name="content"
          rows={5}
          required
          className="mt-1 block w-full rounded-md border px-3 py-2 text-sm"
        />
        {state?.error?.content && (
          <p className="text-xs text-red-500 mt-1">{state.error.content[0]}</p>
        )}
      </div>

      <button
        type="submit"
        disabled={isPending}
        className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
      >
        {isPending ? 'Saving...' : 'Create Post'}
      </button>
    </form>
  );
}

The useActionState hook (new in React 19, formerly useFormState) gives you the action's return value and a pending state without any additional state management.


8. Prisma ORM Setup

Schema

Edit prisma/schema.prisma:

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

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

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String    @unique
  emailVerified DateTime?
  image         String?
  password      String?
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  accounts  Account[]
  sessions  Session[]
  posts     Post[]
}

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String? @db.Text
  access_token      String? @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.Text
  session_state     String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
}

model Post {
  id        String   @id @default(cuid())
  title     String
  content   String   @db.Text
  published Boolean  @default(false)
  authorId  String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
}

Database Client Singleton

Prevent connection pool exhaustion in development by caching the Prisma client on the Node.js global object:

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

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

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

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

Running Migrations

# Create and apply the initial migration
npx prisma migrate dev --name init

# Generate the Prisma Client after schema changes
npx prisma generate

# Open Prisma Studio to browse data locally
npx prisma studio

For a free Postgres database during development, use Neon, Supabase, or a local Docker container:

docker run --name pg-dev -e POSTGRES_PASSWORD=password -p 5432:5432 -d postgres

9. NextAuth.js v5 Authentication

NextAuth.js v5 (also published as next-auth@beta) was rewritten to work natively with the App Router. The configuration lives in a single file.

Auth Configuration

// src/lib/auth.ts
import NextAuth from 'next-auth';
import { PrismaAdapter } from '@auth/prisma-adapter';
import GitHub from 'next-auth/providers/github';
import Credentials from 'next-auth/providers/credentials';
import { compare } from 'bcryptjs';
import { db } from '@/lib/db';
import { z } from 'zod';

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(db),
  session: { strategy: 'jwt' },
  pages: {
    signIn: '/login',
    error: '/login',
  },
  providers: [
    GitHub({
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }),
    Credentials({
      async authorize(credentials) {
        const parsed = z
          .object({ email: z.string().email(), password: z.string().min(8) })
          .safeParse(credentials);

        if (!parsed.success) return null;

        const user = await db.user.findUnique({
          where: { email: parsed.data.email },
        });

        if (!user || !user.password) return null;

        const passwordsMatch = await compare(parsed.data.password, user.password);
        if (!passwordsMatch) return null;

        return user;
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) token.id = user.id;
      return token;
    },
    async session({ session, token }) {
      if (session.user) session.user.id = token.id as string;
      return session;
    },
  },
});

Route Handler

Expose the NextAuth.js HTTP endpoints under app/api/auth/[...nextauth]/route.ts:

// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/lib/auth';

export const { GET, POST } = handlers;

Login Form with Server Action

// src/lib/actions/auth.ts
'use server';

import { signIn } from '@/lib/auth';
import { AuthError } from 'next-auth';
import { redirect } from 'next/navigation';

export async function loginWithCredentials(formData: FormData) {
  try {
    await signIn('credentials', {
      email: formData.get('email'),
      password: formData.get('password'),
      redirect: false,
    });
  } catch (error) {
    if (error instanceof AuthError) {
      return { error: 'Invalid email or password.' };
    }
    throw error;
  }
  redirect('/dashboard');
}
// src/app/(auth)/login/page.tsx
import { loginWithCredentials } from '@/lib/actions/auth';
import { signIn } from '@/lib/auth';

export default function LoginPage() {
  return (
    <div className="max-w-sm mx-auto mt-20 space-y-6">
      <h1 className="text-2xl font-bold text-center">Sign in</h1>

      {/* GitHub OAuth */}
      <form
        action={async () => {
          'use server';
          await signIn('github', { redirectTo: '/dashboard' });
        }}
      >
        <button
          type="submit"
          className="w-full flex items-center justify-center gap-2 px-4 py-2 border rounded-md hover:bg-gray-50"
        >
          Continue with GitHub
        </button>
      </form>

      <div className="relative">
        <div className="absolute inset-0 flex items-center">
          <span className="w-full border-t" />
        </div>
        <div className="relative flex justify-center text-xs uppercase">
          <span className="bg-white px-2 text-gray-500">Or</span>
        </div>
      </div>

      {/* Credentials */}
      <form action={loginWithCredentials} className="space-y-4">
        <input
          name="email"
          type="email"
          placeholder="Email"
          required
          className="w-full rounded-md border px-3 py-2 text-sm"
        />
        <input
          name="password"
          type="password"
          placeholder="Password"
          required
          className="w-full rounded-md border px-3 py-2 text-sm"
        />
        <button
          type="submit"
          className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
        >
          Sign in
        </button>
      </form>
    </div>
  );
}

10. API Routes vs Route Handlers

In the App Router, pages/api/ is replaced by app/api/ Route Handlers. Route Handlers are standard Web API Request and Response objects, making them portable and easy to test.

// src/app/api/posts/route.ts
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export async function GET(request: NextRequest) {
  const session = await auth();
  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 });

  const { searchParams } = new URL(request.url);
  const page = Number(searchParams.get('page') ?? '1');
  const limit = 20;

  const posts = await db.post.findMany({
    where: { authorId: session.user.id },
    orderBy: { createdAt: 'desc' },
    skip: (page - 1) * limit,
    take: limit,
    select: { id: true, title: true, published: true, createdAt: true },
  });

  return Response.json({ posts, page });
}

export async function POST(request: NextRequest) {
  const session = await auth();
  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 });

  const body = await request.json();

  const post = await db.post.create({
    data: {
      title: body.title,
      content: body.content,
      authorId: session.user.id,
    },
  });

  return Response.json(post, { status: 201 });
}

When to use Route Handlers vs Server Actions:

  • Use Server Actions for mutations triggered by forms or user interactions in Server/Client Components. They are simpler — no serialisation layer, no URL, no HTTP method to manage.
  • Use Route Handlers when you need a real HTTP endpoint: webhooks, third-party integrations, mobile app backends, or public APIs that external consumers call.

11. Middleware: Auth Protection and Redirects

middleware.ts at the project root (i.e., src/middleware.ts) runs on the Edge before any route is rendered. It is the right place to protect routes and handle redirects.

// src/middleware.ts
import { auth } from '@/lib/auth';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const PUBLIC_PATHS = ['/', '/login', '/register', '/api/auth'];

export default auth(function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const isPublic = PUBLIC_PATHS.some((path) => pathname.startsWith(path));

  // @ts-expect-error — auth() augments the request with `auth`
  const session = request.auth;

  if (!isPublic && !session) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('callbackUrl', pathname);
    return NextResponse.redirect(loginUrl);
  }

  if (session && (pathname === '/login' || pathname === '/register')) {
    return NextResponse.redirect(new URL('/dashboard', request.url));
  }

  return NextResponse.next();
});

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

NextAuth.js v5 exports an auth wrapper that makes the session available directly on the request object inside middleware, removing the need for a separate getToken() call.


12. Environment Variables and Secrets Management

Create a .env.local file (never commit this to source control):

# Database
DATABASE_URL="postgresql://postgres:password@localhost:5432/myapp"

# NextAuth.js
AUTH_SECRET="your-32-character-secret-here"   # generate: openssl rand -base64 32
AUTH_URL="http://localhost:3000"

# GitHub OAuth App
GITHUB_CLIENT_ID="your-github-client-id"
GITHUB_CLIENT_SECRET="your-github-client-secret"

Typed Environment Variables

Add src/env.ts for validated, typed access to environment variables:

// src/env.ts
import { z } from 'zod';

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  AUTH_SECRET: z.string().min(32),
  AUTH_URL: z.string().url(),
  GITHUB_CLIENT_ID: z.string(),
  GITHUB_CLIENT_SECRET: z.string(),
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
});

export const env = envSchema.parse(process.env);

Import env instead of process.env throughout the codebase. If a required variable is missing at startup, you get a clear schema error rather than a cryptic runtime failure.

Exposing Variables to the Browser

Any variable prefixed NEXT_PUBLIC_ is bundled into the client JavaScript. Never prefix secrets with NEXT_PUBLIC_.

NEXT_PUBLIC_APP_URL="https://myapp.com"   # safe — not a secret
DATABASE_URL="..."                         # server-only — no prefix

13. Deploying to Vercel

Step 1: Push to GitHub

git init
git add .
git commit -m "initial commit"
gh repo create my-app --public --push --source=.

Step 2: Import Project on Vercel

Go to vercel.com/new, connect your GitHub account, and import the repository. Vercel detects Next.js automatically and sets the correct build command (next build) and output directory (.next).

Step 3: Configure Environment Variables

In the Vercel dashboard, open Settings > Environment Variables and add all the variables from .env.local. For production, use a managed Postgres database. Vercel Postgres (powered by Neon) can be provisioned directly from the dashboard and injects DATABASE_URL automatically.

Generate a production AUTH_SECRET:

openssl rand -base64 32

Set AUTH_URL to your production domain: https://myapp.vercel.app.

Step 4: Run Migrations on Deploy

Add a postinstall script to package.json to run migrations automatically during the Vercel build:

{
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start",
    "postinstall": "prisma generate",
    "migrate:deploy": "prisma migrate deploy"
  }
}

Add a build command override in Vercel: prisma migrate deploy && next build. This ensures migrations run before the app is deployed.

Step 5: Preview Deployments

Every pull request automatically gets a preview deployment at a unique URL (e.g., my-app-git-feature-xyz.vercel.app). To use a separate preview database, add a DATABASE_URL variable scoped to the Preview environment in Vercel's dashboard.

Step 6: Custom Domain

In Settings > Domains, add your custom domain. Vercel provisions a TLS certificate automatically via Let's Encrypt. Update AUTH_URL to match your custom domain.

Vercel-Specific Optimisations

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  experimental: {
    // Enable the `after()` API
    after: true,
  },
  images: {
    // Allow avatars from GitHub
    remotePatterns: [{ hostname: 'avatars.githubusercontent.com' }],
  },
};

export default nextConfig;

Frequently Asked Questions

Q: Do I need a separate Express/Fastify backend? No. Next.js 15 covers the full stack: Server Components handle read operations, Server Actions handle write operations, and Route Handlers expose HTTP endpoints when needed. A separate backend server adds operational complexity without benefit for most applications.

Q: Can I use Drizzle ORM instead of Prisma? Yes. Drizzle is a popular alternative with a SQL-first API and excellent TypeScript inference. The patterns in this tutorial — singleton client, calling queries in Server Components, revalidating after mutations — apply regardless of which ORM you choose.

Q: How do I handle file uploads? Use a dedicated storage service (Vercel Blob, AWS S3, Cloudflare R2). Upload directly from the browser to the storage provider using a presigned URL that your Server Action generates, then save the resulting URL to the database. Avoid sending large files through Next.js Route Handlers.

Q: Is the App Router production-ready? Yes, and has been since Next.js 14. The Vercel platform itself and many large applications run on it. The stability concerns from early 2024 around caching and Server Actions edge cases have been resolved.

Q: How do I handle database connection limits in serverless? Serverless functions each open their own database connection. With Prisma and Postgres, use a connection pooler such as PgBouncer or the connection pooling built into Neon and Supabase. Set ?pgbouncer=true&connection_limit=1 on your DATABASE_URL when using PgBouncer.

Q: What is the difference between revalidatePath and revalidateTag? revalidatePath('/dashboard') purges the cached result for a specific URL. revalidateTag('posts') purges every fetch() call or Prisma query that was tagged with 'posts', regardless of which page it appeared on. Tags are more precise when the same data is displayed on multiple pages.

Q: Should I use the jwt or database session strategy with NextAuth.js? Use jwt for stateless Edge-compatible sessions (no database read on every request). Use database when you need to invalidate sessions server-side (e.g., on account suspension). The Prisma Adapter is required for the database strategy.


Sources

Leonardo Lazzaro

Software engineer and technical writer. 10+ years experience in DevOps, Python, and Linux systems.

More articles by Leonardo Lazzaro