Next.js 14 with the App Router is the dominant full-stack React framework — combining server-side rendering, static generation, API routes, and server actions in one codebase. Deployed on a Hong Kong VPS with CN2 GIA routing, a Next.js application serves server-rendered pages and API responses to mainland Chinese users at 30–60ms — far better than Vercel’s US or EU infrastructure, with no per-seat or per-invocation pricing.
Why Self-Host Next.js Instead of Vercel?
- China accessibility — Vercel’s edge network is not optimised for mainland China; your HK VPS with CN2 GIA delivers dramatically better TTFB for Chinese users
- Cost at scale — Vercel Pro costs $20/month per team member plus function invocation fees; a VPS is a flat rate regardless of traffic
- Full server access — run PostgreSQL, Redis, and cron jobs on the same server; no need for external database services
- No function timeout limits — Vercel limits serverless functions to 10–60 seconds; VPS has no such constraint for long-running operations
Step 1: Install Node.js and PM2
<code">apt update && apt upgrade -y curl -fsSL https://deb.nodesource.com/setup_20.x | bash - apt install -y nodejs nginx certbot python3-certbot-nginx ufw npm install -g pm2 typescript node --version # v20.x npm --version
Step 2: Configure Next.js for Self-Hosting
<code"># next.config.ts — key settings for self-hosted production
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// Required for standalone output (single deployable folder)
output: 'standalone',
// Enable React strict mode
reactStrictMode: true,
// Image optimisation — allow your domain
images: {
domains: ['yourdomain.com', 'cdn.yourdomain.com'],
formats: ['image/avif', 'image/webp'],
},
// Headers for security and caching
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
],
},
{
// Cache static assets for 1 year
source: '/_next/static/(.*)',
headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }],
},
]
},
// Redirect www to non-www (or vice versa)
async redirects() {
return [
{
source: '/:path*',
has: [{ type: 'host', value: 'www.yourdomain.com' }],
destination: 'https://yourdomain.com/:path*',
permanent: true,
},
]
},
}
export default nextConfigStep 3: Environment Variables
<code"># .env.production (on VPS — never commit to git) DATABASE_URL=postgresql://nextjs_user:Password!@localhost/nextjs_db NEXTAUTH_URL=https://yourdomain.com NEXTAUTH_SECRET=GENERATE_64_CHAR_SECRET_HERE REDIS_URL=redis://:RedisPass@localhost:6379/0 # For server components and API routes only (not exposed to browser): DATABASE_URL=... # For client-side use (prefixed with NEXT_PUBLIC_): NEXT_PUBLIC_API_URL=https://yourdomain.com/api NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
Step 4: Build for Production
<code"># On your VPS (or build locally and transfer) useradd -m -s /bin/bash nextjs mkdir -p /var/www/myapp chown nextjs:nextjs /var/www/myapp su - nextjs cd /var/www/myapp git clone https://github.com/your-org/myapp.git . cp /path/to/.env.production .env.production npm ci --production=false # Install ALL deps for build npm run build # Creates .next/standalone/ # The standalone folder is self-contained ls .next/standalone/ # server.js node_modules/ .next/ public/ package.json
Step 5: PM2 Process Manager
<code">cat > /var/www/myapp/ecosystem.config.js << 'EOF'
module.exports = {
apps: [{
name: 'nextjs',
cwd: '/var/www/myapp/.next/standalone',
script: 'server.js',
instances: 'max', // One worker per CPU core
exec_mode: 'cluster', // Load-balanced cluster mode
env_production: {
NODE_ENV: 'production',
PORT: 3000,
HOSTNAME: '127.0.0.1',
},
max_memory_restart: '1G',
error_file: '/var/log/nextjs/error.log',
out_file: '/var/log/nextjs/out.log',
merge_logs: true,
time: true,
// Graceful shutdown
kill_timeout: 5000,
listen_timeout: 10000,
}]
}
EOF
mkdir -p /var/log/nextjs
chown nextjs:nextjs /var/log/nextjs
pm2 start /var/www/myapp/ecosystem.config.js --env production
pm2 save
# Auto-start on server reboot
pm2 startup systemd -u nextjs --hp /home/nextjs
# Run the generated command as rootStep 6: Nginx Reverse Proxy
<code">cat > /etc/nginx/sites-available/nextjs << 'EOF'
upstream nextjs {
server 127.0.0.1:3000;
keepalive 64;
}
# Nginx-level caching for static assets
proxy_cache_path /var/cache/nginx/nextjs
levels=1:2
keys_zone=nextjs_cache:10m
max_size=1g
inactive=60m;
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;
# Next.js static files — cache at Nginx level
location /_next/static/ {
alias /var/www/myapp/.next/standalone/.next/static/;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Public folder (images, favicon, etc.)
location /public/ {
alias /var/www/myapp/.next/standalone/public/;
expires 30d;
add_header Cache-Control "public";
}
# All other requests → Next.js
location / {
proxy_pass http://nextjs;
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;
proxy_read_timeout 60s;
}
}
EOF
mkdir -p /var/cache/nginx/nextjs
ln -s /etc/nginx/sites-available/nextjs /etc/nginx/sites-enabled/
certbot --nginx -d yourdomain.com
nginx -t && systemctl reload nginxStep 7: Database — Drizzle ORM with PostgreSQL
<code"># Install Drizzle ORM (recommended for Next.js + TypeScript)
npm install drizzle-orm pg drizzle-kit @types/pg
# db/schema.ts
import { pgTable, uuid, text, timestamp, boolean } from 'drizzle-orm/pg-core'
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: text('email').notNull().unique(),
name: text('name').notNull(),
createdAt: timestamp('created_at').defaultNow(),
active: boolean('active').default(true),
})
export const posts = pgTable('posts', {
id: uuid('id').defaultRandom().primaryKey(),
title: text('title').notNull(),
slug: text('slug').notNull().unique(),
content: text('content'),
authorId: uuid('author_id').references(() => users.id),
publishedAt: timestamp('published_at'),
})
# db/index.ts
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
})
export const db = drizzle(pool)Usage in Server Components
<code"># app/posts/page.tsx — Server Component (runs on VPS, not in browser)
import { db } from '@/db'
import { posts, users } from '@/db/schema'
import { desc, eq } from 'drizzle-orm'
export default async function PostsPage() {
// This query runs on the VPS — direct DB connection, no API overhead
const allPosts = await db
.select({
title: posts.title,
slug: posts.slug,
author: users.name,
publishedAt: posts.publishedAt,
})
.from(posts)
.leftJoin(users, eq(posts.authorId, users.id))
.orderBy(desc(posts.publishedAt))
.limit(20)
return (- {allPosts.map(post => (
- {post.title} — {post.author}
))}
) }
Step 8: Zero-Downtime Deployment Script
<code"># deploy.sh — run from your local machine or CI/CD #!/bin/bash set -e VPS_HOST="root@YOUR_VPS_IP" APP_DIR="/var/www/myapp" echo "Building Next.js..." npm run build echo "Transferring standalone build..." rsync -az --delete \ .next/standalone/ \ $VPS_HOST:$APP_DIR/.next/standalone/ rsync -az --delete \ .next/static/ \ $VPS_HOST:$APP_DIR/.next/standalone/.next/static/ rsync -az --delete \ public/ \ $VPS_HOST:$APP_DIR/.next/standalone/public/ echo "Running migrations..." ssh $VPS_HOST "cd $APP_DIR && npm run db:migrate" echo "Reloading PM2 (zero-downtime)..." ssh $VPS_HOST "pm2 reload nextjs --update-env" echo "Verifying..." sleep 3 ssh $VPS_HOST "curl -sf http://127.0.0.1:3000/api/health && echo 'OK'" echo "Deployment complete"
Performance Benchmarks: Next.js on HK VPS vs Vercel
| Metric | Vercel (US/EU edge) | HK VPS CN2 GIA |
|---|---|---|
| TTFB from Shanghai (SSR page) | 180–350ms | 35–65ms |
| TTFB from Shanghai (static) | 80–150ms | 15–30ms |
| TTFB from Hong Kong | 60–120ms | 8–20ms |
| Monthly cost (medium traffic) | $20–100+ | Flat VPS rate |
| DB query latency (on same server) | N/A (external DB) | 0.5–2ms |
Conclusion
Next.js deployed on a Hong Kong VPS with PM2 cluster mode, Nginx reverse proxy, and PostgreSQL on the same server delivers full-stack TypeScript applications to mainland Chinese users at 35–65ms TTFB — 3–5× faster than Vercel’s international infrastructure. The standalone build mode creates a self-contained deployable folder; PM2’s cluster mode utilises all CPU cores; and Nginx caches static assets locally, making the architecture both fast and cost-predictable.
Deploy your Next.js app: Browse Server.HK Hong Kong VPS plans — a 4 GB plan handles Next.js with PM2 cluster, PostgreSQL, and Redis for most production workloads.