AI Reading
Quick summary of this article
Ship a Next.js 16 app safely: Vitest and Playwright tests, environment validation, security headers, standalone output, Docker and PM2 deployment on a VPS, plus a launch checklist.
- Test Server Actions and data functions with Vitest, and cover user journeys with Playwright — do not unit-test implementation details of Server Components.
output: "standalone"produces a self-contained server bundle that needs only.next/standalone,.next/staticandpublicin production.- Run
prisma migrate deployandnext buildas separate steps so a failed migration never leaves a half-deployed app. - A production Next.js 16 app needs Node.js 20.9+ — verify the runtime on the server, not just locally.
Complete Next.js 16 Series — Module 12 of 12
You have built routing, data fetching, mutations, a database and auth. The last step is making sure it survives real users: tests that catch regressions, a deploy that is reproducible, and a checklist that prevents the classic production failures.
Testing Strategy for the App Router
Three layers, in order of value:
- End-to-end (Playwright) — real browser, real routes, real auth. Highest confidence per test.
- Integration (Vitest) — Server Actions, Zod schemas, data functions against a test database.
- Unit (Vitest) — pure helpers: formatters, permissions logic, URL builders.
Skip shallow component tests of Server Components. Their behaviour is the data layer plus routing, both of which the two layers above cover properly.
Setting Up Vitest
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom vite-tsconfig-paths
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths(), react()],
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
include: ["src/**/*.test.{ts,tsx}"],
},
});
// vitest.setup.ts
import "@testing-library/jest-dom/vitest";
Test the validation schema — it is the contract your Server Actions depend on:
// src/lib/schemas.test.ts
import { describe, expect, it } from "vitest";
import { contactSchema } from "./schemas";
describe("contactSchema", () => {
it("accepts a valid submission", () => {
const result = contactSchema.safeParse({
name: "Rahim",
email: "rahim@example.com",
message: "I would like to know more about the course.",
});
expect(result.success).toBe(true);
});
it("rejects a short message", () => {
const result = contactSchema.safeParse({
name: "Rahim",
email: "rahim@example.com",
message: "Hi",
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toEqual(["message"]);
}
});
it("rejects an invalid email", () => {
const result = contactSchema.safeParse({
name: "Rahim",
email: "not-an-email",
message: "A long enough message body.",
});
expect(result.success).toBe(false);
});
});
Test authorisation logic — the highest-risk code you have:
// src/lib/authz.test.ts
import { describe, expect, it, vi } from "vitest";
vi.mock("@/auth", () => ({
auth: vi.fn(async () => ({ user: { id: "u1", role: "EDITOR" } })),
}));
import { requireRole } from "./authz";
describe("requireRole", () => {
it("allows an editor to satisfy EDITOR", async () => {
await expect(requireRole("EDITOR")).resolves.toMatchObject({ id: "u1" });
});
it("blocks an editor from ADMIN", async () => {
await expect(requireRole("ADMIN")).rejects.toThrow("FORBIDDEN");
});
});
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test"
}
}
End-to-End with Playwright
npm install -D @playwright/test
npx playwright install --with-deps chromium
// tests/auth.spec.ts
import { test, expect } from "@playwright/test";
test("anonymous users are redirected from the dashboard", async ({ page }) => {
await page.goto("/dashboard");
await expect(page).toHaveURL(//login?callbackUrl=%2Fdashboard/);
});
test("an editor can publish a post", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill(process.env.E2E_EDITOR_EMAIL!);
await page.getByLabel("Password").fill(process.env.E2E_EDITOR_PASSWORD!);
await page.getByRole("button", { name: "Sign in" }).click();
await page.goto("/admin/posts/new");
await page.getByLabel("Title").fill("Playwright smoke test");
await page.getByLabel("Slug").fill("playwright-smoke-test");
await page.getByLabel("Body").fill("Lorem ipsum dolor sit amet, consectetur adipiscing elit.");
await page.getByRole("button", { name: "Publish" }).click();
await expect(page.getByText("Playwright smoke test")).toBeVisible();
});
Run the suite against a built app in CI, not the dev server:
npm run build && npm start &
npx playwright test
Validate Environment Variables at Boot
A missing DATABASE_URL should crash at startup, not on the first request:
// src/lib/env.ts
import "server-only";
import { z } from "zod";
const schema = z.object({
DATABASE_URL: z.string().url(),
AUTH_SECRET: z.string().min(32),
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error("❌ Invalid environment variables:", z.flattenError(parsed.error).fieldErrors);
throw new Error("Invalid environment variables");
}
export const env = parsed.data;
Import it once from src/lib/db.ts or src/auth.ts so it runs on any server boot.
Security Headers
// next.config.ts
const nextConfig: NextConfig = {
async headers() {
return [
{
source: "/:path*",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
],
},
];
},
};
export default nextConfig;
Test any new dependency’s CSP requirements before enabling a strict Content-Security-Policy — it is easy to ship one that breaks your own fonts or analytics.
Standalone Output
For self-hosting, a self-contained bundle is far easier to deploy:
const nextConfig: NextConfig = {
output: "standalone",
};
After npm run build, copy these three things to the server:
.next/standalone → app root (contains node_modules and server.js)
.next/static → .next/standalone/.next/static
public → .next/standalone/public
Then start it:
node .next/standalone/server.js
Deploying on a VPS with PM2
This is the workflow for a plain Linux server — the same one you would use on any Node host.
1. Verify the runtime version. Next.js 16 requires Node.js 20.9+:
node -v # must be >= v20.9.0
If the default Node on the server is older than the app requires, point the process at the newer binary explicitly.
2. Build on the server (or upload the standalone output):
cd /path/to/app
npm ci
npx prisma generate
npx prisma migrate deploy
npm run build
3. Run with PM2:
npm install -g pm2
pm2 start .next/standalone/server.js --name nextjs-app --update-env
pm2 save
pm2 startup
Set the environment variables PM2 should pass through:
PORT=3000
HOSTNAME=127.0.0.1
NODE_ENV=production
DATABASE_URL=postgresql://...
AUTH_SECRET=...
AUTH_URL=https://yourdomain.com
4. Put a reverse proxy in front. Nginx terminates TLS and forwards to 127.0.0.1:3000:
server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 120s;
}
}
proxy_set_header X-Forwarded-Proto is essential: without it Auth.js generates http:// callback URLs and OAuth logins fail with a redirect mismatch.
5. Health check and reload:
pm2 describe nextjs-app
curl -sf http://127.0.0.1:3000/api/health || echo "DOWN"
pm2 reload nextjs-app # zero-downtime restart
pm2 reload keeps at least one instance serving while the new one boots. Use pm2 restart only in a pinch.
6. Or use Docker — the same standalone output in a reproducible image:
FROM node:22-alpine AS base
WORKDIR /app
FROM base AS deps
COPY package*.json ./
RUN npm ci
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate && npm run build
FROM base AS runner
ENV NODE_ENV=production
RUN addgroup -g 1001 nodejs && adduser -S -u 1001 -G nodejs nextjs
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
USER nextjs
EXPOSE 3000
ENV PORT=3000 HOSTNAME=0.0.0.0
CMD ["node", "server.js"]
The Pre-Launch Checklist
npm run buildpasses with zero type errors and no hydration warnings.npm run testandnpm run test:e2eare green against a production build.AUTH_SECRET,DATABASE_URLand every API key exist in production and nowhere in git.npx prisma migrate deployhas run and the schema matches.- Every Server Action calls
requireUser()/requireRole()and verifies resource ownership. proxy.tsmatchercovers only the routes that need protection.- Images have
sizes;priorityis on one image only. robots.txtandsitemap.tsreflect the production domain.- 404 and 500 pages exist and are branded.
- Backups: database dump scheduled, restore actually tested once.
- Error monitoring is wired up (Sentry or an equivalent) with source maps.
pm2 savehas run so the app restarts after a reboot.
Where to Go Next
You now have the complete stack. Sensible next steps, in order of value:
- Rebuild an existing project with the App Router and Server Actions, deleting every
useEffectfetch. - Add a CMS or headless API and consume it with
use cacheplus tag-based invalidation. - Introduce real-time with Server-Sent Events in a Route Handler.
- Learn React 19.2 features —
useEffectEvent,Activity,ViewTransition. - Harden operations — tracing, structured logs, and a rollback procedure you have rehearsed.
Series Recap
Across twelve modules you covered the full lifecycle of a modern Next.js application: the App Router and file conventions, async request APIs, Server and Client Components, the caching model with use cache, Server Actions with Zod validation, Route Handlers, Prisma 7 with PostgreSQL, Auth.js v5 with proxy.ts, performance work, and a production deployment.
The through-line: do work on the server, cache deliberately, invalidate precisely, and authorise at the data layer. Everything else is detail.
Continue the Course
- Course index: Next.js 16 Tutorial – all 12 modules
- Previous: Module 11: Performance & Streaming
