Skip to main content
Advertisement

13.5 Deployment — Vercel/Cloudflare Deployment, Environment Variable Management, ISR Strategy

Once your Next.js app is complete, it's time to share it with the world. Deployment is the process of putting your code onto a real server or edge network so that users can access it. Next.js 15 supports a wide range of platforms including Vercel, Cloudflare Pages, AWS, and Docker.


Deployment Platform Comparison

PlatformFeaturesFree TierBest For
VercelNext.js optimized, auto deploy100GB bandwidth/monthDefault choice for Next.js projects
Cloudflare PagesGlobal edge, Workers integrationUnlimited requestsGlobal services, edge computing
AWS AmplifyAWS ecosystem integration1,000 build minutes/monthTeams using AWS infrastructure
RailwaySimple container deployment$5 credit/monthFull-stack apps with DB
Self-hostedFull controlServer costs onlyEnterprise, special requirements

Vercel Deployment

What is Vercel?

Vercel is a deployment platform operated by the company that created Next.js. It supports all Next.js features (ISR, Edge Runtime, Server Components, etc.) with zero configuration.

Basic Deployment (GitHub Integration)

# 1. Install Vercel CLI
npm install -g vercel

# 2. Log in
vercel login

# 3. Deploy from project root
vercel

# 4. Deploy to production
vercel --prod

When you connect your GitHub repository to Vercel, it automatically deploys every time you push to the main branch.

vercel.json Configuration

{
"version": 2,
"regions": ["icn1", "sin1"],
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "Strict-Transport-Security",
"value": "max-age=31536000; includeSubDomains"
}
]
},
{
"source": "/api/(.*)",
"headers": [
{
"key": "Cache-Control",
"value": "no-store"
}
]
}
],
"rewrites": [
{
"source": "/old-path",
"destination": "/new-path"
}
]
}

Vercel Per-Environment Configuration

# Set development environment variable
vercel env add DATABASE_URL development

# Preview environment
vercel env add DATABASE_URL preview

# Production environment
vercel env add DATABASE_URL production

# List all environment variables
vercel env ls

Cloudflare Pages Deployment

Cloudflare Pages + Next.js

Cloudflare Pages uses the @cloudflare/next-on-pages adapter to run Next.js on top of Workers.

# Install adapter
npm install -D @cloudflare/next-on-pages

# Install wrangler
npm install -D wrangler

next.config.ts Configuration

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
// Edge Runtime settings for Cloudflare Pages
experimental: {
// Enable as needed
},
};

export default nextConfig;

wrangler.toml Configuration

# wrangler.toml
name = "my-next-app"
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = ".vercel/output/static"

[[kv_namespaces]]
binding = "CACHE"
id = "your-kv-namespace-id"

[vars]
NODE_ENV = "production"

package.json Scripts

{
"scripts": {
"dev": "next dev",
"build": "next build",
"build:cf": "npx @cloudflare/next-on-pages",
"preview": "npm run build:cf && wrangler pages dev",
"deploy": "npm run build:cf && wrangler pages deploy"
}
}

Cloudflare Pages Deploy Command

# Build and deploy
npm run deploy

# Or as separate steps
npx @cloudflare/next-on-pages
wrangler pages deploy .vercel/output/static --project-name=my-next-app

Environment Variable Management

.env File Hierarchy

.env                  # Base values for all environments (safe to commit)
.env.local # Local-only overrides (git ignored)
.env.development # Development environment only
.env.production # Production environment only
.env.test # Test environment only

Priority: .env.local > .env.{NODE_ENV} > .env

Client vs Server Environment Variables

# .env.local

# Server-only (never exposed to client)
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
JWT_SECRET=super-secret-key-never-expose
STRIPE_SECRET_KEY=sk_live_xxxx
SENDGRID_API_KEY=SG.xxxx

# Also exposed to client (NEXT_PUBLIC_ prefix required)
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxxx

Environment Variable Usage Examples

// app/lib/db.ts — runs on server only
import { Pool } from 'pg';

// Accessible in Server Components, API Routes, and Server Actions
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
});

export default pool;
// app/components/Analytics.tsx — Client Component
'use client';

import { useEffect } from 'react';

export function Analytics() {
useEffect(() => {
// Only NEXT_PUBLIC_ variables are accessible on the client
const gaId = process.env.NEXT_PUBLIC_GA_ID;
if (gaId) {
// Initialize Google Analytics
gtag('config', gaId);
}
}, []);

return null;
}

Environment Variable Validation

// app/lib/env.ts
import { z } from 'zod';

// Server environment variable schema
const serverEnvSchema = z.object({
DATABASE_URL: z.string().url('A valid DB URL is required'),
JWT_SECRET: z.string().min(32, 'JWT secret must be at least 32 characters'),
NODE_ENV: z.enum(['development', 'test', 'production']),
STRIPE_SECRET_KEY: z.string().startsWith('sk_').optional(),
});

// Client environment variable schema
const clientEnvSchema = z.object({
NEXT_PUBLIC_API_URL: z.string().url(),
NEXT_PUBLIC_GA_ID: z.string().optional(),
});

// Runs on server side only
function validateServerEnv() {
const parsed = serverEnvSchema.safeParse({
DATABASE_URL: process.env.DATABASE_URL,
JWT_SECRET: process.env.JWT_SECRET,
NODE_ENV: process.env.NODE_ENV,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
});

if (!parsed.success) {
console.error('❌ Environment variable validation failed:');
console.error(parsed.error.format());
throw new Error('Please check your environment variable configuration.');
}

return parsed.data;
}

export const env = validateServerEnv();

ISR (Incremental Static Regeneration) Strategy

What is ISR?

ISR is a core Next.js feature that allows static pages to be updated periodically even after deployment. It combines the benefits of SSG (fast responses) and SSR (fresh data).

Request 1 (no cache) → Render on server → Store in cache
Requests 2–N (cache valid) → Serve from cache instantly
First request after revalidate expires → Return cache + regenerate in background
Next request → Serve new page

Time-based ISR

// app/blog/[slug]/page.tsx
interface Props {
params: Promise<{ slug: string }>;
}

// Revalidate every 60 seconds
export const revalidate = 60;

async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 60 },
});

if (!res.ok) throw new Error('Could not load post');
return res.json();
}

export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return posts.map((post: { slug: string }) => ({ slug: post.slug }));
}

export default async function BlogPost({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);

return (
<article>
<h1>{post.title}</h1>
<p className="text-gray-500">
Last updated: {new Date(post.updatedAt).toLocaleDateString('en-US')}
</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}

On-Demand ISR (Webhook-based Revalidation)

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
// Security: validate secret token
const token = request.headers.get('x-revalidate-token');
if (token !== process.env.REVALIDATE_TOKEN) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const body = await request.json();
const { type, slug, tag } = body;

try {
if (type === 'path' && slug) {
// Revalidate specific path
revalidatePath(`/blog/${slug}`);
revalidatePath('/blog'); // also revalidate the list page
return NextResponse.json({
revalidated: true,
message: `/blog/${slug} revalidated successfully`,
});
}

if (type === 'tag' && tag) {
// Tag-based revalidation (finer-grained control)
revalidateTag(tag);
return NextResponse.json({
revalidated: true,
message: `Tag '${tag}' revalidated successfully`,
});
}

return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
} catch (error) {
return NextResponse.json(
{ error: 'Error during revalidation' },
{ status: 500 }
);
}
}

fetch Tag-based Cache Management

// app/lib/api.ts
import { unstable_cache } from 'next/cache';

// Product list — cached with 'products' tag
export async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: {
tags: ['products'],
revalidate: 3600, // 1 hour
},
});
return res.json();
}

// Specific product — cached with 'products' and 'product-{id}' tags
export async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: {
tags: ['products', `product-${id}`],
revalidate: 300, // 5 minutes
},
});
return res.json();
}

// Cache DB queries with unstable_cache
export const getCachedUser = unstable_cache(
async (userId: string) => {
// Direct DB queries can also be cached
const user = await db.user.findUnique({ where: { id: userId } });
return user;
},
['user'], // cache key
{
tags: ['users'],
revalidate: 60,
}
);

Real-World Example: Full Blog Platform Deployment Pipeline

Project Structure

my-blog/
├── app/
│ ├── blog/
│ │ ├── page.tsx # Blog list (ISR 60s)
│ │ └── [slug]/
│ │ └── page.tsx # Individual post (ISR 300s)
│ ├── api/
│ │ └── revalidate/
│ │ └── route.ts # Webhook revalidation endpoint
│ └── layout.tsx
├── .env.local
├── .env.production
├── vercel.json
└── next.config.ts

Complete Blog List Page

// app/blog/page.tsx
import { Suspense } from 'react';
import Link from 'next/link';

export const revalidate = 60;

interface Post {
id: string;
slug: string;
title: string;
excerpt: string;
publishedAt: string;
author: { name: string; avatar: string };
tags: string[];
}

async function getPosts(): Promise<Post[]> {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/posts`, {
next: { tags: ['posts'], revalidate: 60 },
});

if (!res.ok) {
throw new Error('Could not load post list');
}

return res.json();
}

function PostCard({ post }: { post: Post }) {
return (
<article className="border rounded-lg p-6 hover:shadow-md transition-shadow">
<Link href={`/blog/${post.slug}`}>
<h2 className="text-xl font-bold mb-2 hover:text-blue-600">
{post.title}
</h2>
</Link>
<p className="text-gray-600 mb-4">{post.excerpt}</p>
<div className="flex items-center justify-between text-sm text-gray-500">
<span>{post.author.name}</span>
<time dateTime={post.publishedAt}>
{new Date(post.publishedAt).toLocaleDateString('en-US')}
</time>
</div>
<div className="flex gap-2 mt-3">
{post.tags.map(tag => (
<span key={tag} className="bg-blue-100 text-blue-700 px-2 py-0.5 rounded text-xs">
{tag}
</span>
))}
</div>
</article>
);
}

export default async function BlogPage() {
const posts = await getPosts();

return (
<main className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">Blog</h1>
<div className="grid gap-6">
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
</main>
);
}

CI/CD Pipeline (GitHub Actions)

# .github/workflows/deploy.yml
name: Deploy to Vercel

on:
push:
branches: [main]
pull_request:
branches: [main]

env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Type check
run: npm run type-check

- name: Lint
run: npm run lint

- name: Unit tests
run: npm test

deploy-preview:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Deploy Vercel preview
run: |
npm install -g vercel
vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
vercel build --token=${{ secrets.VERCEL_TOKEN }}
vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }}

deploy-production:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Deploy Vercel production
run: |
npm install -g vercel
vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}

Performance Monitoring and Analysis

next.config.ts Performance Settings

// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
// Bundle analysis
...(process.env.ANALYZE === 'true' && {
// Used with @next/bundle-analyzer
}),

// Image optimization
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [
{
protocol: 'https',
hostname: 'images.example.com',
pathname: '/uploads/**',
},
],
minimumCacheTTL: 86400, // 24 hours
},

// Compression
compress: true,

// Remove X-Powered-By header
poweredByHeader: false,

// Security headers
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-DNS-Prefetch-Control', value: 'on' },
{ key: 'X-XSS-Protection', value: '1; mode=block' },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin',
},
],
},
];
},

// Redirects
async redirects() {
return [
{
source: '/old-blog/:slug',
destination: '/blog/:slug',
permanent: true,
},
];
},
};

export default nextConfig;

Pro Tips

Tip 1: Automated Pre-Deploy Checklist

// scripts/pre-deploy-check.ts
import { execSync } from 'child_process';

const checks = [
{ name: 'Type check', cmd: 'npx tsc --noEmit' },
{ name: 'Lint', cmd: 'npx eslint . --ext .ts,.tsx' },
{ name: 'Build', cmd: 'npm run build' },
{ name: 'Env vars', cmd: 'node -e "require(\'./app/lib/env\')"' },
];

async function runChecks() {
console.log('🚀 Starting pre-deploy checks...\n');

for (const check of checks) {
process.stdout.write(` ${check.name}... `);
try {
execSync(check.cmd, { stdio: 'pipe' });
console.log('✅');
} catch (error) {
console.log('❌');
console.error(`\n${check.name} failed:`);
process.exit(1);
}
}

console.log('\n✅ All checks passed! Ready to deploy.');
}

runChecks();

Tip 2: ISR Cache Warming Strategy

// scripts/warm-cache.ts
// Pre-load critical pages into cache after deployment
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL!;

const criticalPaths = [
'/',
'/blog',
'/products',
'/about',
];

async function warmCache() {
console.log('Starting cache warm-up...');

await Promise.allSettled(
criticalPaths.map(async (path) => {
const url = `${BASE_URL}${path}`;
const res = await fetch(url);
console.log(`${res.ok ? '✅' : '❌'} ${url} (${res.status})`);
})
);

console.log('Cache warm-up complete!');
}

warmCache();

Tip 3: Multi-Region Deployment Configuration

{
"regions": ["icn1", "sin1", "sfo1", "fra1"],
"functions": {
"app/api/**": {
"maxDuration": 30,
"memory": 1024
}
}
}

Tip 4: Per-Environment Feature Flags

// app/lib/feature-flags.ts
type Environment = 'development' | 'preview' | 'production';

const flags: Record<string, Record<Environment, boolean>> = {
newDashboard: {
development: true,
preview: true,
production: false, // Not yet in production
},
betaSearch: {
development: true,
preview: false,
production: false,
},
};

export function isFeatureEnabled(feature: string): boolean {
const env = (process.env.VERCEL_ENV || 'development') as Environment;
return flags[feature]?.[env] ?? false;
}

// Usage:
// if (isFeatureEnabled('newDashboard')) { ... }
Advertisement