This document explains the architectural decisions behind the Next.js Starter Kit — why things are structured the way they are, and how to extend the architecture as your app grows.
├── app/ # Next.js App Router pages and layouts
│ ├── api/ # API route handlers (serverless functions)
│ │ ├── auth/route.ts # Authentication endpoints
│ │ ├── posts/route.ts # Blog/content CRUD
│ │ └── users/route.ts # User management
│ ├── blog/ # Public blog with ISR
│ │ ├── page.tsx # Blog listing (ISR, 60s revalidation)
│ │ └── [slug]/page.tsx # Individual post (ISR + generateStaticParams)
│ ├── dashboard/ # Protected area
│ │ ├── layout.tsx # Dashboard shell (sidebar, nav)
│ │ └── page.tsx # Dashboard home
│ ├── layout.tsx # Root layout (HTML shell, global providers)
│ ├── page.tsx # Landing page (Server Component)
│ ├── loading.tsx # Global loading skeleton
│ ├── error.tsx # Global error boundary
│ └── not-found.tsx # 404 page
├── components/ # Shared React components
│ ├── Header.tsx # Site header with auth-aware navigation
│ ├── Footer.tsx # Site footer with link columns
│ └── PostCard.tsx # Blog post card (3 variants)
├── src/
│ ├── lib/ # Core libraries and utilities
│ │ ├── auth.ts # JWT auth, password hashing, session management
│ │ ├── prisma.ts # Prisma client singleton
│ │ └── utils.ts # Shared utilities (slugify, formatting, etc.)
│ └── types/ # TypeScript type definitions
│ └── index.ts # Shared types (User, Post, ApiResponse, etc.)
├── prisma/
│ └── schema.prisma # Database schema
├── middleware.ts # Edge middleware for route protection
├── next.config.ts # Next.js configuration
├── vercel.json # Vercel deployment configuration
└── package.json # Dependencies and scripts
We use the App Router (introduced in Next.js 13, stable in 14) instead of the legacy Pages Router. Here's why:
| Feature | Pages Router | App Router (this starter) |
|---|---|---|
| Rendering model | Pages default to client-side | Components default to server-side |
| Data fetching | getServerSideProps, getStaticProps | async Server Components, fetch() with caching |
| Layouts | Custom _app.tsx wrapper | Nested layout.tsx files (preserved across navigation) |
| Loading states | Manual implementation | Built-in loading.tsx per route segment |
| Error handling | Custom _error.tsx | Built-in error.tsx per route segment |
| Metadata | Manual management | metadata export or generateMetadata() |
| Streaming | Not supported | Built-in with |
The App Router is the future of Next.js. All new features ship for App Router first.
Every component in this starter is a Server Component unless explicitly marked with "use client". This matters for three reasons:
1. Bundle size — Server Components send zero JavaScript to the client. The PostCard, Footer, and most of the app/ pages ship as pure HTML.
2. Data access — Server Components can directly query the database, read files, or call APIs without exposing credentials to the client. No need for an API route as a middleman.
3. Performance — Server Components stream HTML progressively. The user sees content faster because the browser doesn't need to download, parse, and execute JavaScript before rendering.
When to use "use client":
onClick, onChange, onSubmit)useState, useEffect, useRef, usePathname)In this starter, only Header.tsx needs "use client" (for the mobile menu toggle and active link detection).
We implement authentication from scratch using JWTs instead of using NextAuth.js. This is deliberate:
Why not NextAuth?
How our auth works:
Login Request → Validate credentials → Create JWT → Set HTTP-only cookie
│
Subsequent requests → Middleware reads cookie → Verifies JWT → Allows/denies
│
API routes → getCurrentUser() reads cookie → Returns user or null
Key security features:
We use Prisma because:
prisma.user.findUnique() returns a fully typed User object.prisma migrate dev handles schema changes with version-controlled SQL migrations.provider in schema.prisma.The singleton pattern (src/lib/prisma.ts):
// Without singleton: Hot Module Replacement creates a new PrismaClient
// on every file save, eventually exhausting database connections.
//
// With singleton: One PrismaClient instance is stored on `globalThis`
// and reused across hot reloads.
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}The middleware.ts file runs on Vercel's Edge Runtime (or Node.js locally). It intercepts every request before it reaches your pages.
Why middleware instead of per-page auth checks?
Route matching strategy:
// Protected routes — require authentication
const PROTECTED_PREFIXES = ["/dashboard", "/settings", "/api/users"];
// Auth routes — redirect to dashboard if already logged in
const AUTH_ROUTES = ["/login", "/signup"];The middleware checks the JWT cookie, verifies it, and either allows the request or redirects. It does NOT query the database (that would be slow on every request). The JWT contains the user's ID and role, which is enough for authorization decisions.
The blog pages use ISR — a hybrid between static and dynamic rendering:
// app/blog/page.tsx
export const revalidate = 60; // Regenerate at most every 60 secondsHow ISR works:
1. First visitor → Next.js renders the page server-side and caches it
2. Next 60 seconds → All visitors get the cached HTML (instant)
3. After 60 seconds → Next visitor triggers a background regeneration
4. Background regeneration completes → New HTML replaces the cache
5. All subsequent visitors get the fresh page
This gives you the performance of a static site with the freshness of server rendering. Blog posts update within 60 seconds of a database change, without a full rebuild.
generateStaticParams for dynamic routes:
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await prisma.post.findMany({
where: { published: true },
select: { slug: true },
});
return posts.map((post) => ({ slug: post.slug }));
}This pre-generates pages for all existing posts at build time. New posts are rendered on-demand and then cached.
User visits /blog → Server Component renders
│
Prisma query (server-side, no API call)
│
Returns HTML with post data
│
Browser displays page (zero JS for data fetching)
User clicks "Create Post" → Client Component form
│
fetch("/api/posts", { method: "POST", body: ... })
│
API route validates → Prisma insert → Returns JSON
│
Client updates UI (optimistic or after response)
Client sends request → API route handler
│
getCurrentUser(request) reads cookie
│
JWT valid? → Query database with user context
JWT invalid? → Return 401
1. Create app/your-page/page.tsx
2. Add the route prefix to PROTECTED_PREFIXES in middleware.ts
3. Use getCurrentUser() in the page for user-specific data
1. Create app/api/your-resource/route.ts
2. Export GET, POST, PUT, or DELETE functions
3. Use getCurrentUser(request) for authenticated endpoints
4. Return NextResponse.json() with appropriate status codes
1. Add the model to prisma/schema.prisma
2. Run npx prisma migrate dev --name add_your_model
3. Add the TypeScript type to src/types/index.ts
4. Create an API route at app/api/your-model/route.ts
For real-time updates (chat, notifications, live dashboards), you have several options:
1. Server-Sent Events (SSE) — Simple, one-way server-to-client. Create a streaming API route.
2. WebSockets — Two-way communication. Requires a separate WebSocket server (not supported on Vercel Serverless).
3. Polling — Simple but less efficient. Use setInterval with fetch() in a client component.
4. Third-party services — Pusher, Ably, or Supabase Realtime for managed WebSockets.
The current schema has individual users. To add teams:
1. Add Team and TeamMember models to Prisma
2. Add teamId to the JWT payload
3. Update middleware to check team membership
4. Add team-scoped queries (where: { teamId })
| Optimization | Implementation | Impact |
|---|---|---|
| Server Components | Default — no "use client" unless needed | 50-80% less client JS |
| ISR for blog | revalidate = 60 on listing pages | Static-speed for reads |
| Image optimization | Use next/image with width/height | Automatic WebP, lazy loading |
| Font optimization | Use next/font/google in root layout | No layout shift, preloaded |
| Route prefetching | Automatic for in viewport | Instant navigation |
| Edge middleware | Auth checks at the edge, not in pages | <1ms auth overhead |
| Prisma singleton | Single connection pool across hot reloads | No connection exhaustion |
next.config.ts)Part of Frontend Developer Pro
This guide covers deploying your Next.js starter kit to production. We cover Vercel (recommended), Docker, and traditional Node.js hosting.
Before deploying, ensure you have:
1. A PostgreSQL database — Vercel Postgres, Supabase, Neon, PlanetScale, or any PostgreSQL provider
2. Environment variables — Copy .env.example to .env and fill in all values
3. Prisma migrations applied — Run npx prisma migrate deploy against your production database
Vercel is the company behind Next.js. Their platform is optimized for Next.js applications with zero-config deployments.
# 1. Install the Vercel CLI
npm i -g vercel
# 2. Link your project (first time only)
vercel link
# 3. Set environment variables
vercel env add DATABASE_URL # Your PostgreSQL connection string
vercel env add JWT_SECRET # Random 64-character string
vercel env add NEXT_PUBLIC_APP_URL # https://your-domain.com
# 4. Deploy to preview
vercel
# 5. Deploy to production
vercel --prodThe included vercel.json handles:
{
// Redirect www to non-www (or vice versa)
// Security headers on all routes
// Cache control for static assets
// Function timeout and memory limits
}Set these in the Vercel dashboard (Settings → Environment Variables) or via CLI:
| Variable | Description | Example |
|---|---|---|
DATABASE_URL | PostgreSQL connection string | postgresql://user:pass@host:5432/db?sslmode=require |
JWT_SECRET | Secret for signing JWTs (min 32 chars) | Generate with openssl rand -hex 32 |
NEXT_PUBLIC_APP_URL | Your production URL | https://your-app.vercel.app |
NODE_ENV | Environment flag | production (Vercel sets this automatically) |
If you're using Vercel Postgres:
# 1. Create the database in the Vercel dashboard
# Storage → Create → Postgres
# 2. Vercel automatically adds DATABASE_URL to your environment
# 3. Run migrations
npx prisma migrate deploy
# 4. (Optional) Seed initial data
npx prisma db seed# Add your domain
vercel domains add your-domain.com
# Vercel will provide DNS records to configure:
# - A record: 76.76.21.21
# - CNAME: cname.vercel-dns.com (for www)Every push to a non-production branch creates a preview deployment with:
your-app-git-feature-branch.vercel.app)For self-hosting or cloud providers that support containers (AWS ECS, Google Cloud Run, DigitalOcean App Platform, Railway).
Create a Dockerfile in your project root:
# =============================================================================
# Multi-stage Docker build for Next.js
# =============================================================================
# Stage 1: Install dependencies
# Stage 2: Build the application
# Stage 3: Production runtime (minimal image)
# =============================================================================
# --- Stage 1: Dependencies ---
FROM node:20-alpine AS deps
WORKDIR /app
# Copy package files first for better layer caching
# If package.json hasn't changed, Docker reuses this layer
COPY package.json package-lock.json ./
COPY prisma ./prisma/
RUN npm ci --omit=dev
RUN npx prisma generate
# --- Stage 2: Build ---
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects anonymous telemetry data about usage.
# Disable it in Docker builds to avoid network calls during build.
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# --- Stage 3: Production Runtime ---
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Create a non-root user for security
# Running as root in a container is a security risk — if the app is
# compromised, the attacker has root access to the container.
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy only the files needed for production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Health check — Docker and orchestrators use this to know if the
# container is healthy. If the health check fails, the container is
# restarted automatically.
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
CMD ["node", "server.js"]Add this to your next.config.ts:
const nextConfig = {
output: "standalone",
// ... rest of your config
};The standalone output creates a self-contained server.js that doesn't need node_modules. This reduces the Docker image size from ~500MB to ~100MB.
# Build the image
docker build -t nextjs-starter .
# Run locally
docker run -p 3000:3000 \
-e DATABASE_URL="postgresql://user:pass@host:5432/db" \
-e JWT_SECRET="your-secret-here" \
-e NEXT_PUBLIC_APP_URL="http://localhost:3000" \
nextjs-starter
# Run database migrations (one-time)
docker run --rm \
-e DATABASE_URL="postgresql://user:pass@host:5432/db" \
nextjs-starter \
npx prisma migrate deployCreate a docker-compose.yml:
version: "3.9"
services:
# PostgreSQL database
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: devuser
POSTGRES_PASSWORD: devpassword
POSTGRES_DB: nextjs_starter
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U devuser -d nextjs_starter"]
interval: 5s
timeout: 5s
retries: 5
# Next.js application
app:
build: .
restart: unless-stopped
ports:
- "3000:3000"
environment:
DATABASE_URL: "postgresql://devuser:devpassword@db:5432/nextjs_starter"
JWT_SECRET: "local-development-secret-change-in-production"
NEXT_PUBLIC_APP_URL: "http://localhost:3000"
depends_on:
db:
condition: service_healthy
volumes:
postgres_data:# Start everything
docker compose up -d
# Run migrations
docker compose exec app npx prisma migrate deploy
# View logs
docker compose logs -f app
# Stop everything
docker compose downFor VPS providers (DigitalOcean Droplets, Hetzner, Linode) or any server with Node.js.
# Install dependencies
npm ci
# Generate Prisma client
npx prisma generate
# Run database migrations
npx prisma migrate deploy
# Build the application
npm run build
# Start the production server
npm start
# → Listening on http://localhost:3000Use PM2 to keep your app running and restart on crashes:
# Install PM2 globally
npm i -g pm2
# Start the app with PM2
pm2 start npm --name "nextjs-starter" -- start
# Configure auto-restart on server reboot
pm2 startup
pm2 save
# Monitor
pm2 monit
# View logs
pm2 logs nextjs-starterCreate ecosystem.config.js for more control:
module.exports = {
apps: [
{
name: "nextjs-starter",
script: "npm",
args: "start",
instances: "max", // Use all CPU cores
exec_mode: "cluster", // Cluster mode for load balancing
env_production: {
NODE_ENV: "production",
PORT: 3000,
},
// Restart if memory exceeds 512MB
max_memory_restart: "512M",
// Graceful shutdown
kill_timeout: 5000,
// Log configuration
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
error_file: "./logs/error.log",
out_file: "./logs/output.log",
merge_logs: true,
},
],
};Nginx sits in front of your Node.js app, handling SSL, static files, and load balancing:
# /etc/nginx/sites-available/nextjs-starter
# Redirect HTTP to HTTPS
server {
listen 80;
server_name your-domain.com www.your-domain.com;
return 301 https://$server_name$request_uri;
}
# Main HTTPS server
server {
listen 443 ssl http2;
server_name your-domain.com;
# SSL certificates (use Let's Encrypt / certbot)
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# SSL configuration (Mozilla Modern profile)
ssl_protocols TLSv1.3;
ssl_prefer_server_ciphers off;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml application/xml+rss text/javascript
image/svg+xml;
# Next.js static files — served directly by Nginx (faster than Node.js)
location /_next/static/ {
alias /var/www/nextjs-starter/.next/static/;
expires 365d;
access_log off;
add_header Cache-Control "public, immutable";
}
# Public files
location /public/ {
alias /var/www/nextjs-starter/public/;
expires 30d;
access_log off;
}
# Proxy everything else to Next.js
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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_cache_bypass $http_upgrade;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}# Install certbot
sudo apt install certbot python3-certbot-nginx
# Get SSL certificate (auto-configures Nginx)
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
# Auto-renewal is configured automatically via systemd timer
# Verify with:
sudo certbot renew --dry-run# Create a migration after schema changes
npx prisma migrate dev --name describe_your_change
# This does three things:
# 1. Generates a SQL migration file in prisma/migrations/
# 2. Applies the migration to your dev database
# 3. Regenerates the Prisma client# Apply pending migrations (non-interactive, safe for CI/CD)
npx prisma migrate deploy
# This ONLY applies existing migrations — it never creates new ones.
# If a migration fails, it stops and reports the error.Add to your deployment script (GitHub Actions example):
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
# Run migrations before deployment
# If this fails, the deployment is aborted
- name: Run database migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
- name: Build
run: npm run build
# Deploy to your platform of choice
- name: Deploy to Vercel
run: vercel --prod --token ${{ secrets.VERCEL_TOKEN }}# .env.local (never committed)
DATABASE_URL="postgresql://devuser:devpassword@localhost:5432/nextjs_dev"
JWT_SECRET="development-only-secret-not-for-production"
NEXT_PUBLIC_APP_URL="http://localhost:3000"DATABASE_URL="postgresql://staging_user:xxx@staging-db.example.com:5432/nextjs_staging?sslmode=require"
JWT_SECRET="staging-secret-different-from-production"
NEXT_PUBLIC_APP_URL="https://staging.your-domain.com"DATABASE_URL="postgresql://prod_user:xxx@prod-db.example.com:5432/nextjs_prod?sslmode=require&connection_limit=20"
JWT_SECRET="cryptographically-random-64-char-string"
NEXT_PUBLIC_APP_URL="https://your-domain.com"# Install Vercel analytics
npm i @vercel/analytics @vercel/speed-insightsAdd to your root layout:
import { Analytics } from "@vercel/analytics/react";
import { SpeedInsights } from "@vercel/speed-insights/next";
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<Analytics />
<SpeedInsights />
</body>
</html>
);
}Add a health check for load balancers and monitoring:
// app/api/health/route.ts
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
export async function GET() {
try {
// Check database connectivity
await prisma.$queryRaw`SELECT 1`;
return NextResponse.json({
status: "healthy",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
});
} catch (error) {
return NextResponse.json(
{
status: "unhealthy",
timestamp: new Date().toISOString(),
error: "Database connection failed",
},
{ status: 503 }
);
}
}For production error tracking, consider:
1. Sentry — npm i @sentry/nextjs with automatic source maps
2. LogRocket — Session replay + error tracking
3. Axiom — Log aggregation with Vercel integration
Before going to production:
output: "standalone" in next.config.ts (for Docker)images.domains for allowed external image sourcesCache-Control headers for API routes that can be cachedrevalidate valuesnext/font for Google Fonts (eliminates layout shift)next/image for all images (automatic optimization)dynamic()npx next build locally and check the output for large pages1. Check that DATABASE_URL is set correctly in your environment
2. Verify the database server is running and accepting connections
3. Check firewall rules — your deployment server must be able to reach the database
4. If using Vercel, ensure the database allows connections from Vercel's IP ranges
The app requires a JWT_SECRET environment variable. Generate one:
openssl rand -hex 32Set it in your deployment environment (Vercel dashboard, Docker env, or .env file).
Run the build locally first to reproduce:
npm run buildFix all TypeScript errors before deploying. The strict: true setting in tsconfig.json catches many issues at build time.
By default, Next.js API routes only accept same-origin requests. If you're calling from a different domain:
// Add to your API route handler
export async function GET(request: Request) {
const response = NextResponse.json(data);
response.headers.set("Access-Control-Allow-Origin", "https://your-frontend.com");
response.headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
return response;
}Part of Frontend Developer Pro