Next.js Deployment Alternatives to Vercel: A Comprehensive Guide
A practical guide to deploying Next.js beyond Vercel, covering the cost profile, implementation details, and migration path for each target platform.
Vercel gives Next.js the smoothest deploy experience available, but the bill scales faster than the traffic. Bandwidth overages run $0.15/GB and function invocations accumulate quietly, so a single marketing spike can multiply costs at the exact moment conversions matter most. The platform-specific APIs that make deployment easy also create lock-in that complicates any future move.
Cloudflare Workers with the OpenNext adapter is the default worth trying first for most Next.js applications: bandwidth is not metered, and the adapter keeps the application portable if the answer changes later. SST on AWS and a Docker VPS behind a CDN cover the cases where that default does not fit. Each target carries its own cost profile and its own migration details that the docs leave out.
Why Teams Are Looking Beyond Vercel
Vercel offers an excellent developer experience, yet several factors drive teams to explore alternatives:
-
Vendor Lock-in Concerns: Vercel’s platform-specific APIs and deployment patterns create dependencies that make future migrations challenging. Teams find themselves tied to proprietary features that don’t translate to other platforms.
-
Single Point of Dependency: Relying on one vendor for critical infrastructure introduces risk. When Vercel experiences outages or changes their pricing model, teams have limited recourse.
-
Cost at Scale: Bandwidth beyond the included quota bills at $0.15/GB ($150 per terabyte), and function invocations add up on top of that. Traffic spikes therefore hit the bill and the plan limits at the same time. The plan ceiling is worth checking while a campaign is still being planned.
Additional factors that influence the decision:
- Need for specific regional compliance or data residency
- Desire to leverage existing cloud infrastructure investments
- Requirements for custom caching rules or deployment configurations
- Budget constraints that don’t align with Vercel’s pricing tiers
Evaluating Deployment Options
Four axes decide the target: feature support, cost structure, performance needs, and the capacity of the team that will run it.
Managed Platform Alternatives
AWS Amplify - The Enterprise-Ready Choice
AWS Amplify has matured significantly for Next.js deployments. Here’s what a production configuration looks like:
# amplify.yml
version: 1
frontend:
phases:
preBuild:
commands:
- npm ci --cache .npm --prefer-offline
# Fix for sharp/image optimization
- npm install --os=linux --cpu=x64 sharp
build:
commands:
- npm run build
artifacts:
baseDirectory: .next
files:
- '**/*'
cache:
paths:
- .npm/**/*
- node_modules/**/*
# Custom cache configuration
customHeaders:
- pattern: '**/*'
headers:
- key: 'Cache-Control'
value: 'public, max-age=31536000, immutable'
- pattern: '**/*.html'
headers:
- key: 'Cache-Control'
value: 'public, max-age=0, must-revalidate'
Key Implementation Details:
- Build minutes cost $0.01 each (typical builds: 2-4 minutes)
- Bandwidth pricing: $0.15/GB after 15GB free tier
- Supports large numbers of redirects, though console performance degrades with thousands of rules
- Automatic branch deployments for pull requests
Real-World Gotcha: The redirect console performance issue isn’t documented. This becomes apparent when migrating legacy applications with 2,000+ redirects. The solution? Implement redirects at the application level using middleware:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Load redirects from a JSON file or database
import redirects from './redirects.json';
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// Check if pathname needs redirect
const redirect = redirects[pathname];
if (redirect) {
return NextResponse.redirect(
new URL(redirect.destination, request.url),
redirect.permanent ? 308 : 307
);
}
return NextResponse.next();
}
export const config = {
matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)',
};
Cloudflare Workers - The Low-Cost Default
Cloudflare with the OpenNext adapter has become surprisingly capable. The implementation has two approaches, and they target different products:
Option 1: Edge-Only Runtime on Pages (Limited but Fast)
npm install @cloudflare/next-on-pages
Option 2: Full Node.js Support on Workers (Recommended)
npm install @opennextjs/cloudflare
Here’s a production-ready configuration using OpenNext:
// next.config.mjs
import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare';
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '**.cloudinary.com',
},
],
},
};
export default nextConfig;
// Exposes Cloudflare bindings to `next dev`
initOpenNextCloudflareForDev();
# wrangler.toml
name = "nextjs-production"
main = ".open-next/worker.js"
# nodejs_compat requires a compatibility date of 2024-09-23 or later
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
[assets]
directory = ".open-next/assets"
binding = "ASSETS"
[vars]
ENVIRONMENT = "production"
[[d1_databases]]
binding = "DB"
database_name = "production"
database_id = "your-database-id"
[[kv_namespaces]]
binding = "CACHE"
id = "your-kv-namespace-id"
The adapter emits a Worker, not a Pages project. Deployment runs through opennextjs-cloudflare build and opennextjs-cloudflare deploy.
Cost Analysis:
- Bandwidth: not metered, at any volume
- Requests: 100,000/day on the Workers free tier
- Above that tier: the Workers Paid plan, which starts at $5/month
- Total monthly cost for a low-traffic application: $0
Netlify - The Developer-Friendly Middle Ground
Netlify’s Next.js support has improved dramatically. Here’s a configuration that handles complex requirements:
# netlify.toml
[build]
command = "npm run build"
publish = ".next"
[[plugins]]
package = "@netlify/plugin-nextjs"
[build.environment]
NEXT_USE_NETLIFY_EDGE = "true"
NETLIFY_NEXT_PLUGIN_SKIP = "false"
# Function configuration for API routes
[functions]
directory = "netlify/functions"
included_files = ["data/**"]
# Redirect rules with splat support
[[redirects]]
from = "/old-blog/*"
to = "/posts/:splat"
status = 301
force = true
# Custom headers for security
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
X-XSS-Protection = "1; mode=block"
Netlify’s built-in form handling works with Next.js without a third-party service, which saves a moving part on lead-capture sites. Other platforms need an external endpoint for the same job.
Self-Hosting Solutions - Maximum Control
SST on AWS - Serverless with Full AWS Access
SST (formerly Serverless Stack) provides the best serverless deployment experience for Next.js. Here’s a complete production setup:
// sst.config.ts
import { SSTConfig } from "sst";
import { NextjsSite, Bucket, Table } from "sst/constructs";
export default {
config(_input) {
return {
name: "nextjs-production",
region: "us-east-1",
};
},
stacks(app) {
app.stack(function Site({ stack }) {
// DynamoDB for session storage
const table = new Table(stack, "sessions", {
fields: {
sessionId: "string",
},
primaryIndex: { partitionKey: "sessionId" },
});
// S3 for uploads
const bucket = new Bucket(stack, "uploads", {
cors: [
{
maxAge: "1 day",
allowedOrigins: ["*"],
allowedHeaders: ["*"],
allowedMethods: ["GET", "PUT", "POST", "DELETE", "HEAD"],
},
],
});
// Next.js site
const site = new NextjsSite(stack, "site", {
customDomain: {
domainName: "example.com",
hostedZone: "example.com",
},
environment: {
DATABASE_URL: process.env.DATABASE_URL,
SESSION_TABLE_NAME: table.tableName,
UPLOAD_BUCKET_NAME: bucket.bucketName,
},
bind: [table, bucket],
// Performance optimizations
memorySize: 1024,
timeout: "30 seconds",
// Regional configuration
regional: {
enableServerUrlIamAuth: true,
},
});
stack.addOutputs({
SiteUrl: site.url,
CloudFrontUrl: site.cdk.distribution.distributionDomainName,
});
});
},
} satisfies SSTConfig;
Cost Breakdown for 1M requests/month:
- Lambda: ~$20 (including free tier)
- CloudFront: ~$10 for bandwidth
- S3: ~$1 for storage
- Total: ~$31/month
Docker + VPS - The Fixed-Cost Setup
For teams comfortable with server management, self-hosting on Hetzner or DigitalOcean provides unbeatable value. Here’s a production-grade Docker setup:
# Dockerfile
# Dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on lockfile
COPY package.json package-lock.json* ./
RUN \
if [ -f package-lock.json ]; then npm ci --omit=dev; \
else echo "Lockfile not found." && exit 1; \
fi
# Builder
FROM node:20-alpine AS builder
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build application
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy built application
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]
# docker-compose.yml
version: '3.8'
services:
nextjs:
build: .
restart: unless-stopped
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
networks:
- app-network
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/ssl:/etc/nginx/ssl:ro
- ./nginx/cache:/var/cache/nginx
depends_on:
- nextjs
networks:
- app-network
# Optional: Redis for caching
redis:
image: redis:alpine
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redis-data:/data
networks:
- app-network
networks:
app-network:
driver: bridge
volumes:
redis-data:
Nginx Configuration for Production:
# nginx.conf
upstream nextjs {
server nextjs:3000;
}
server {
listen 80;
server_name example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
# 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;
# Cache static assets
location /_next/static {
proxy_pass http://nextjs;
proxy_cache_valid 365d;
add_header Cache-Control "public, immutable";
}
# Cache images
location /_next/image {
proxy_pass http://nextjs;
proxy_cache_valid 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Everything else
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_cache_bypass $http_upgrade;
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;
}
}
Platform-as-a-Service - Coolify and Alternatives
Coolify has emerged as a powerful self-hosted alternative to Vercel. Installation on a fresh VPS:
# Install Coolify on Ubuntu/Debian
curl -fsSL https://coolify.io/install.sh | bash
There is no config file to commit. Coolify reads the repository and asks for the build command, the start command, the exposed port, a health-check path, and the environment variables through its interface. Per-application CPU and memory limits are set the same way, which is what lets one VPS host several client projects without a single busy site taking the box down.
Cost Comparison
Using each platform’s published pricing, here is the monthly cost profile at three traffic levels:
Note
These are estimates from list prices. An actual bill also moves with request volume, function duration, and region.
| Platform | Small App (10GB/month) | Medium App (500GB/month) | Large App (2TB/month) | Notes |
|---|---|---|---|---|
| Vercel | $20 | $80 | $320 | Predictable but expensive at scale |
| Netlify | $0 (free tier) | $20 | $95+ | Better predictability than Vercel |
| Cloudflare Workers | $0 | $0 | ~$5 | Bandwidth not metered; Workers Paid above the free request tier |
| AWS Amplify | ~$5 | ~$30 | ~$70 | Pay-as-you-go model |
| Hetzner + Cloudflare | EUR3.79 | EUR3.79 | EUR3.79 | Fixed cost regardless of traffic |
| SST on AWS | ~$10 | ~$20-40 | ~$50-100 | Varies by usage patterns |
| DigitalOcean Apps | $5 | $25 | $100 | Simple pricing structure |
Migration Strategy - Week-by-Week Approach
Week 1 - Assessment and Planning
Start from the usage page in the Vercel dashboard and record three numbers per month: bandwidth served, function invocations, and build minutes. Those three drive most of the bill, and they are also the inputs every alternative prices against. Take the last three months rather than the last one, so a quiet month does not set the baseline. Note the peak day separately; the peak is what decides whether a free request tier holds.
Week 2 - Proof of Concept
Deploy a minimal version to your chosen platform:
# Example: Testing a Cloudflare Workers deployment
npx create-next-app@latest test-deployment
cd test-deployment
# Add OpenNext adapter
npm install @opennextjs/cloudflare
# Configure, build, and deploy
npx opennextjs-cloudflare build
npx opennextjs-cloudflare deploy
Week 3 - Production Preparation
Implement monitoring and observability:
// lib/monitoring.ts
import { metrics } from '@opentelemetry/api-metrics';
const meter = metrics.getMeter('nextjs-app', '1.0.0');
// Create custom metrics
const requestDuration = meter.createHistogram('http_request_duration', {
description: 'Duration of HTTP requests in milliseconds',
unit: 'ms',
});
const deploymentCost = meter.createGauge('deployment_cost', {
description: 'Estimated deployment cost in USD',
unit: 'USD',
});
export function trackRequest(route: string, duration: number) {
requestDuration.record(duration, { route });
}
export function updateCost(platform: string, cost: number) {
deploymentCost.record(cost, { platform });
}
Week 4 - Migration and Validation
Execute the migration with a rollback strategy:
# .github/workflows/deploy-with-rollback.yml
name: Deploy with Rollback
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Backup current deployment
run: |
# Save current deployment info for rollback
echo ${{ github.sha }} > .last-known-good
- name: Deploy to new platform
run: |
# Your deployment commands here
npm run deploy:production
- name: Health check
id: health
run: |
# Verify deployment is healthy
curl -f https://your-app.com/api/health || exit 1
- name: Rollback on failure
if: failure()
run: |
# Rollback to previous version
LAST_GOOD=$(cat .last-known-good)
npm run deploy:rollback $LAST_GOOD
Common Pitfalls and Solutions
The Sharp/Image Optimization Challenge
Almost every platform struggles with Next.js image optimization. Here’s the universal solution:
// 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'}`];
const paramsString = params.join(',');
return `https://res.cloudinary.com/your-cloud-name/image/upload/${paramsString}/${src}`;
}
Environment Variable Management
Different platforms handle environment variables differently. Here’s a unified approach:
// lib/config.ts
interface Config {
database: {
url: string;
poolSize: number;
};
redis: {
url: string;
};
platform: 'vercel' | 'amplify' | 'cloudflare' | 'self-hosted';
}
function detectPlatform(): Config['platform'] {
if (process.env.VERCEL) return 'vercel';
if (process.env.AWS_REGION) return 'amplify';
if (globalThis.navigator?.userAgent === 'Cloudflare-Workers') return 'cloudflare';
return 'self-hosted';
}
export const config: Config = {
database: {
url: process.env.DATABASE_URL!,
poolSize: detectPlatform() === 'self-hosted' ? 20 : 1,
},
redis: {
url: process.env.REDIS_URL || 'redis://localhost:6379',
},
platform: detectPlatform(),
};
ISR Cache Behavior Differences
Incremental Static Regeneration behaves differently across platforms:
// pages/api/revalidate.ts
import { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { path, platform } = req.query;
try {
switch (platform) {
case 'vercel':
await res.revalidate(path as string);
break;
case 'cloudflare':
// Cloudflare KV-based revalidation
await fetch(`https://api.cloudflare.com/client/v4/zones/${process.env.CF_ZONE}/purge_cache`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CF_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
files: [`https://example.com${path}`],
}),
});
break;
case 'aws':
// CloudFront invalidation
const cloudfront = new AWS.CloudFront();
await cloudfront.createInvalidation({
DistributionId: process.env.CF_DISTRIBUTION_ID,
InvalidationBatch: {
CallerReference: Date.now().toString(),
Paths: {
Quantity: 1,
Items: [path as string],
},
},
}).promise();
break;
}
res.status(200).json({ revalidated: true });
} catch (err) {
res.status(500).json({ error: 'Failed to revalidate' });
}
}
Performance Trade-offs
Latency follows from where the code runs, not from the platform’s branding. A request answered at an edge location beats one that travels to a single origin region. A container that never scales to zero has no cold start to pay for. A VPS with no CDN in front of it is the slowest option for users far from the machine, and that gap widens with distance rather than with load.
Three points are worth holding onto when comparing targets:
- Put a CDN in front of any self-hosted deployment. Without one, the fixed-cost advantage arrives with a latency penalty for distant users.
- “Zero cold start” claims describe the runtime’s isolate startup, not the application’s own initialization. A heavy module graph and a fresh database connection still cost time on the first request.
- Cold starts are felt most on low-traffic routes that are also latency-sensitive. If the application has those, an always-warm container removes the variable instead of tuning it.
Measure the candidate against your own routes before committing. Published numbers describe someone else’s module graph.
Recommendations Based on Use Case
For Startups and MVPs
Recommendation: Cloudflare Workers with OpenNext
- Zero bandwidth costs removes a major scaling concern
- Free tier handles most startup traffic
- Global performance out of the box
For Enterprise Applications
Recommendation: SST on AWS
- Full AWS service integration
- Infrastructure as code for compliance
- Predictable costs with reserved capacity
For High-Traffic Content Sites
Recommendation: Self-hosted with Cloudflare CDN
- Fixed monthly costs regardless of traffic
- Complete control over caching strategy
- No vendor lock-in
For Agencies and Freelancers
Recommendation: Coolify on Hetzner
- Host unlimited client projects on one VPS
- Simple deployment interface for clients
- Cost-effective at EUR3.79/month per server
Migration Lessons
Four habits keep a future platform change cheap:
Start with OpenNext Compatibility: Design your application to work with OpenNext from day one. This provides maximum flexibility for platform switches without code changes.
Implement Platform-Agnostic Monitoring: Use OpenTelemetry or similar vendor-neutral observability tools rather than platform-specific solutions. This makes migrations much smoother.
Build Cost Tracking Early: Implement cost tracking from the beginning:
// lib/cost-tracker.ts
class DeploymentCostTracker {
private costs: Map<string, number> = new Map();
track(service: string, amount: number) {
const current = this.costs.get(service) || 0;
this.costs.set(service, current + amount);
}
async reportDaily() {
const total = Array.from(this.costs.values()).reduce((a, b) => a + b, 0);
// Send to monitoring service
await fetch('/api/metrics', {
method: 'POST',
body: JSON.stringify({
date: new Date().toISOString(),
costs: Object.fromEntries(this.costs),
total,
}),
});
// Reset for next day
this.costs.clear();
}
}
Design for Multi-Platform Deployment: Maintain deployment configurations for multiple platforms. This provides negotiating power with vendors and quick disaster recovery options.
Choosing a Default
Cloudflare Workers with OpenNext is the option worth trying first. Bandwidth does not meter, the free request tier covers most launches, and the adapter keeps the application portable if the answer changes later.
Two conditions override that default. When the application leans on AWS services that must sit inside the same account and IAM boundary, SST keeps the site and its dependencies in one deployment. When traffic is heavy and steady enough that a fixed server bill beats per-request pricing, a Docker VPS behind a CDN wins on both cost and control.
Staying on Vercel is still defensible. With no operations budget and traffic that fits inside the plan, the migration work costs more than the bill does. The calculation changes once bandwidth and invocations run past the included quota, which is the point to revisit it.
References
- Next.js Self-Hosting Guide - Official Next.js documentation on deploying with Node.js, Docker, and static exports without a managed platform.
- Next.js Deploying to Platforms - Official guide covering deployment to Cloudflare, Netlify, and other verified adapter platforms.
- Cloudflare Workers: Next.js Framework Guide - Official Cloudflare documentation for deploying Next.js applications to Workers with the OpenNext adapter.
- Dokploy Installation Guide - Official documentation for setting up the self-hosted PaaS on a VPS with Docker.
- GitHub Actions Documentation - Reference for building CI/CD workflows used to automate deployment pipelines.
- Amazon CloudFront Developer Guide - Documentation for using CloudFront as a CDN layer when self-hosting Next.js on AWS.
- Vercel Pricing - Plan limits and overage rates for bandwidth, function invocations, and build minutes.
- Cloudflare Workers Pricing - Free and paid request limits that apply to Workers running the OpenNext adapter.
- AWS Amplify Hosting Pricing - Build minute and data transfer rates behind the Amplify cost figures above.
- SST Documentation - Reference for the constructs used in the AWS serverless deployment example.
Related posts
A technical guide to choosing and implementing AWS edge computing for global apps, with practical examples and cost optimization strategies.
The order that makes AWS cost work stick: visibility with Cost Explorer and Budgets, right-sizing with Compute Optimizer, then commitments.
Prompt caching, model routing, token budgets, and semantic caching: how to keep production LLM spend predictable without giving up answer quality.
A guide to Aurora architecture, I/O cost analysis, and when to choose it over RDS, with migration strategies and real-world decision frameworks.
A practical guide to setting up a secure, affordable private server using VPS, Dokploy for deployments, and Cloudflared tunnels for secure access without exposing ports