963 markdown files reformatted with the repository's pinned prettier so pnpm format:check covers the folded tree like every other repo file. The formatter's embedded-language pass also normalized code fences (TS semicolons, closed HTML tags in examples, lowercased CSS hex colors, one renumbered list that skipped an index). Alphanumeric token deltas vs the fold commit were audited file-by-file; all are formatter-equivalent markup normalizations plus the four sanitized skills.
8.6 KiB
Self-Hosting Next.js
Deploy Next.js outside of Vercel with confidence.
Quick Start: Standalone Output
For Docker or any containerized deployment, use standalone output:
// next.config.js
module.exports = {
output: 'standalone',
};
This creates a minimal standalone folder with only production dependencies:
.next/
├── standalone/
│ ├── server.js # Entry point
│ ├── node_modules/ # Only production deps
│ └── .next/ # Build output
└── static/ # Must be copied separately
Docker Deployment
Dockerfile
FROM node:20-alpine AS base
# Install dependencies
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# Build
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Production
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy standalone output
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
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
Docker Compose
version: '3.8'
services:
web:
build: .
ports:
- '3000:3000'
environment:
- NODE_ENV=production
restart: unless-stopped
healthcheck:
test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3000/api/health']
interval: 30s
timeout: 10s
retries: 3
PM2 Deployment
For traditional server deployments:
// ecosystem.config.js
module.exports = {
apps: [
{
name: 'nextjs',
script: '.next/standalone/server.js',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
PORT: 3000,
},
},
],
};
npm run build
pm2 start ecosystem.config.js
ISR and Cache Handlers
The Problem
ISR (Incremental Static Regeneration) uses filesystem caching by default. This breaks with multiple instances:
- Instance A regenerates page → saves to its local disk
- Instance B serves stale page → doesn't see Instance A's cache
- Load balancer sends users to random instances → inconsistent content
Solution: Custom Cache Handler
Next.js 14+ supports custom cache handlers for shared storage:
// next.config.js
module.exports = {
cacheHandler: require.resolve('./cache-handler.js'),
cacheMaxMemorySize: 0, // Disable in-memory cache
};
Redis Cache Handler Example
// cache-handler.js
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
const CACHE_PREFIX = 'nextjs:';
module.exports = class CacheHandler {
constructor(options) {
this.options = options;
}
async get(key) {
const data = await redis.get(CACHE_PREFIX + key);
if (!data) return null;
const parsed = JSON.parse(data);
return {
value: parsed.value,
lastModified: parsed.lastModified,
};
}
async set(key, data, ctx) {
const cacheData = {
value: data,
lastModified: Date.now(),
};
// Set TTL based on revalidate option
if (ctx?.revalidate) {
await redis.setex(CACHE_PREFIX + key, ctx.revalidate, JSON.stringify(cacheData));
} else {
await redis.set(CACHE_PREFIX + key, JSON.stringify(cacheData));
}
}
async revalidateTag(tags) {
// Implement tag-based invalidation
// This requires tracking which keys have which tags
}
};
S3 Cache Handler Example
// cache-handler.js
const { S3Client, GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.CACHE_BUCKET;
module.exports = class CacheHandler {
async get(key) {
try {
const response = await s3.send(
new GetObjectCommand({
Bucket: BUCKET,
Key: `cache/${key}`,
}),
);
const body = await response.Body.transformToString();
return JSON.parse(body);
} catch (err) {
if (err.name === 'NoSuchKey') return null;
throw err;
}
}
async set(key, data, ctx) {
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: `cache/${key}`,
Body: JSON.stringify({
value: data,
lastModified: Date.now(),
}),
ContentType: 'application/json',
}),
);
}
};
What Works vs What Needs Setup
| Feature | Single Instance | Multi-Instance | Notes |
|---|---|---|---|
| SSR | Yes | Yes | No special setup |
| SSG | Yes | Yes | Built at deploy time |
| ISR | Yes | Needs cache handler | Filesystem cache breaks |
| Image Optimization | Yes | Yes | CPU-intensive, consider CDN |
| Middleware | Yes | Yes | Runs on Node.js |
| Edge Runtime | Limited | Limited | Some features Node-only |
revalidatePath/Tag |
Yes | Needs cache handler | Must share cache |
next/font |
Yes | Yes | Fonts bundled at build |
| Draft Mode | Yes | Yes | Cookie-based |
Image Optimization
Next.js Image Optimization works out of the box but is CPU-intensive.
Option 1: Built-in (Simple)
Works automatically, but consider:
- Set
deviceSizesandimageSizesin config to limit variants - Use
minimumCacheTTLto reduce regeneration
// next.config.js
module.exports = {
images: {
minimumCacheTTL: 60 * 60 * 24, // 24 hours
deviceSizes: [640, 750, 1080, 1920], // Limit sizes
},
};
Option 2: External Loader (Recommended for Scale)
Offload to Cloudinary, Imgix, or similar:
// next.config.js
module.exports = {
images: {
loader: 'custom',
loaderFile: './lib/image-loader.js',
},
};
// lib/image-loader.js
export default function cloudinaryLoader({ src, width, quality }) {
const params = ['f_auto', 'c_limit', `w_${width}`, `q_${quality || 'auto'}`];
return `https://res.cloudinary.com/demo/image/upload/${params.join(',')}${src}`;
}
Environment Variables
Build-time vs Runtime
// Available at build time only (baked into bundle)
NEXT_PUBLIC_API_URL=https://api.example.com
// Available at runtime (server-side only)
DATABASE_URL=postgresql://...
API_SECRET=...
Runtime Configuration
For truly dynamic config, don't use NEXT_PUBLIC_*. Instead:
// app/api/config/route.ts
export async function GET() {
return Response.json({
apiUrl: process.env.API_URL,
features: process.env.FEATURES?.split(','),
});
}
OpenNext: Serverless Without Vercel
OpenNext adapts Next.js for AWS Lambda, Cloudflare Workers, etc.
npx create-sst@latest
# or
npx @opennextjs/aws build
Supports:
- AWS Lambda + CloudFront
- Cloudflare Workers
- Netlify Functions
- Deno Deploy
Health Check Endpoint
Always include a health check for load balancers:
// app/api/health/route.ts
export async function GET() {
try {
// Optional: check database connection
// await db.$queryRaw`SELECT 1`;
return Response.json({ status: 'healthy' }, { status: 200 });
} catch (error) {
return Response.json({ status: 'unhealthy' }, { status: 503 });
}
}
Pre-Deployment Checklist
- Build locally first:
npm run build- catch errors before deploy - Test standalone output:
node .next/standalone/server.js - Set
output: 'standalone'for Docker - Configure cache handler for multi-instance ISR
- Set
HOSTNAME="0.0.0.0"for containers - Copy
public/and.next/static/- not included in standalone - Add health check endpoint
- Test ISR revalidation after deployment
- Monitor memory usage - Node.js defaults may need tuning
Testing Cache Handler
Critical: Test your cache handler on every Next.js upgrade:
# Start multiple instances
PORT=3001 node .next/standalone/server.js &
PORT=3002 node .next/standalone/server.js &
# Trigger ISR revalidation
curl http://localhost:3001/api/revalidate?path=/posts
# Verify both instances see the update
curl http://localhost:3001/posts
curl http://localhost:3002/posts
# Should return identical content