AverageDevs
Next.jsDevOps

Deploy Next.js (App Router) with API Routes and Prisma on VPS - Zero to Prod

A practical, copy‑paste deployment guide for shipping a Next.js App Router app with API Routes and Prisma to production. Covers local setup, database provisioning, migrations, env management, Vercel and Docker deploys, health checks, observability, and a final prod checklist.

Deploy Next.js (App Router) with API Routes and Prisma on VPS - Zero to Prod

Shipping a Next.js App Router app that uses API Routes and Prisma is straightforward once you lock down a few production‑critical details: environment variables, database provisioning, connection pooling, migrations, and runtime configuration. This guide is a step‑by‑step, copy‑paste playbook from local to production.

TL;DR

  • Stack: Next.js (App Router) + API Routes + Prisma ORM.
  • Database: Postgres (Neon, Supabase, Railway, PlanetScale for MySQL variant).
  • Deploy: Vercel one‑click or Docker + any container host.
  • Must‑haves: env vars, Prisma migrations in CI/CD, connection pooling, health checks, logging.
  • Security: strict env handling, schema validation, avoid long‑lived DB connections, rate limiting.

1) Create the Project (or adapt your existing app)

# App Router template
pnpm dlx create-next-app@latest my-app --ts --app --eslint --src-dir=false --import-alias "@/*"
cd my-app
pnpm add prisma @prisma/client zod
pnpm add -D tsx prisma-docs-generator

Initialize Prisma:

pnpm dlx prisma init --datasource-provider postgresql

This creates prisma/schema.prisma and .env.

Minimal Prisma schema (users + posts)

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

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

model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  createdAt DateTime @default(now())
  posts     Post[]
}

model Post {
  id        String   @id @default(cuid())
  title     String
  content   String?
  published Boolean  @default(false)
  createdAt DateTime @default(now())
  authorId  String
  author    User     @relation(fields: [authorId], references: [id])
}

Apply initial migration locally:

pnpm prisma migrate dev --name init

Generate the client:

pnpm prisma generate

2) Add a Prisma Client helper with safe re‑use (hot‑reload friendly)

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

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

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

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
  • Prevents multiple clients during dev hot‑reloads.
  • Restrict logs in production; consider query logs only when debugging.

3) Example API Route using Prisma

// app/api/posts/route.ts
import { NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/prisma";

const CreatePostBody = z.object({
  title: z.string().min(1),
  content: z.string().optional(),
});

export const runtime = "nodejs"; // uses Node runtime (good for Prisma drivers)

export const GET = async () => {
  try {
    const posts = await prisma.post.findMany({ orderBy: { createdAt: "desc" } });
    return NextResponse.json({ success: true, data: posts }, { status: 200 });
  } catch (error) {
    return NextResponse.json({ success: false, message: "Failed to fetch posts" }, { status: 500 });
  }
};

export const POST = async (request: Request) => {
  try {
    const json = await request.json();
    const parsed = CreatePostBody.safeParse(json);
    if (!parsed.success) {
      return NextResponse.json({ success: false, message: "Invalid body" }, { status: 400 });
    }

    const post = await prisma.post.create({ data: parsed.data });
    return NextResponse.json({ success: true, data: post }, { status: 201 });
  } catch (error) {
    return NextResponse.json({ success: false, message: "Unexpected server error" }, { status: 500 });
  }
};

Notes:

  • Prefer runtime = "nodejs" for Prisma with Data Proxy disabled.
  • For Edge runtime, use Prisma Data Proxy or HTTP connector.

4) Environment variables

Create .env locally and configure secrets in your host (Vercel, Render, Fly, etc.).

# .env
DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DB?schema=public"
NEXT_PUBLIC_APP_URL="http://localhost:3000"
  • Keep .env out of VCS; use .env.example to document keys.
  • In Vercel, add them in Project Settings → Environment Variables.

Create an example file:

printf "DATABASE_URL=\nNEXT_PUBLIC_APP_URL=\n" > .env.example

5) Local run

pnpm dev
# visit http://localhost:3000

Validate your API routes:

curl -s http://localhost:3000/api/posts | jq

Create a post:

curl -s -X POST http://localhost:3000/api/posts \
  -H 'Content-Type: application/json' \
  -d '{"title":"Hello","content":"First post"}' | jq

6) Provision a production Postgres

Pick one:

  • Neon: generous free tier, serverless, connection pooling.
  • Supabase: Postgres + auth + storage.
  • Railway/Render/Fly/Crunchy: managed Postgres.

Grab the connection string and set DATABASE_URL in your host.

Pooling tip: If your platform has connection limits, enable PgBouncer / pooled connection string.


  • Push to GitHub/GitLab.
  • Import repo in Vercel → Select framework: Next.js.
  • Set env vars: DATABASE_URL, NEXT_PUBLIC_APP_URL.
  • Build command: default (next build).

Run migrations on deploy:

# Add a postbuild script to run Prisma migrations on Vercel
pnpm pkg set scripts.postbuild="prisma migrate deploy"

Or explicitly in Vercel Project → Build & Development Settings → Post‑install Command: pnpm prisma migrate deploy.

If using Prisma Data Proxy (optional for Edge runtime):

pnpm prisma generate --data-proxy

Set export const runtime = "edge" in specific routes only when using Data Proxy/HTTP connector.


8) Deployment Path B - Docker + any host

Add a Dockerfile:

# Dockerfile
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm fetch

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules /app/node_modules
COPY . .
RUN corepack enable && pnpm install --frozen-lockfile
RUN pnpm prisma generate
RUN pnpm build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Set to node runtime for Prisma
ENV NEXT_RUNTIME=nodejs
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
# copy prisma folder for migrations at runtime if needed
COPY --from=builder /app/prisma ./prisma

EXPOSE 3000
CMD ["node", "server.js"]

Build and run:

docker build -t my-app:latest .
docker run -p 3000:3000 \
  -e DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DB?schema=public" \
  -e NEXT_PUBLIC_APP_URL="http://localhost:3000" \
  my-app:latest

Run migrations in entrypoint or separate job:

docker run --rm -e DATABASE_URL=... my-app:latest node -e "await (await import('prisma')).migrate?.deploy?.()"
# or simply exec inside the container
# docker exec <cid> pnpm prisma migrate deploy

Consider a minimal docker-compose.yml for local + db:

version: "3.9"
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_USER: postgres
      POSTGRES_DB: app
    ports:
      - "5432:5432"
  web:
    build: .
    environment:
      DATABASE_URL: postgresql://postgres:postgres@db:5432/app?schema=public
      NEXT_PUBLIC_APP_URL: http://localhost:3000
    ports:
      - "3000:3000"
    depends_on:
      - db

9) CI/CD: run migrations safely

GitHub Actions example:

# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: ["main"]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: pnpm install --frozen-lockfile
      - run: pnpm prisma generate
      - run: pnpm build
      - run: pnpm prisma migrate deploy
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
  • Use migrate deploy in CI for idempotent production migrations.
  • Avoid migrate dev in CI; it’s for local development.

10) Health checks, logging, and metrics

  • Add a lightweight health route:
// app/api/health/route.ts
import { NextResponse } from "next/server";
export const GET = async () => NextResponse.json({ ok: true, ts: Date.now() });
  • Centralize error logging; in Vercel, console output appears in Logs. For advanced needs, add Sentry/Logtail and mask PII.
  • Track DB metrics via your provider; watch connection count, slow queries, errors.

11) Common production pitfalls

  • Missing DATABASE_URL in production environments.
  • Exhausting Postgres connections on serverless without pooling.
  • Running migrate dev in CI, causing drift.
  • Using Edge runtime with Prisma without Data Proxy/HTTP connector.
  • Forgetting to rerun prisma generate after schema changes.

12) Final production checklist

  • Env: all required vars set in host; .env.example updated.
  • DB: provisioned with pooling; prisma migrate deploy succeeds.
  • Runtime: API routes using Node runtime unless Data Proxy is configured.
  • Migrations: run in CI/CD or post‑build hook.
  • Health: /api/health responds 200.
  • Observability: logs aggregated; error alerts set.
  • Security: dependencies up‑to‑date; no leaked secrets; rate‑limit sensitive routes.
  • Backups: automated DB backups enabled and tested.

With these pieces in place, you can confidently ship a Next.js App Router app with API Routes and Prisma to production on Vercel or any container platform. Copy the snippets, wire up your envs, and deploy. Good luck!