Skip to content

Stack · Vercel

Vercel is the deploy story Next.js was designed for, configured to stay predictable

Preview URLs on every PR, edge runtime where it pays off, on-demand revalidation that drops the cache exactly when content changes. We architect the Vercel deployment so the bill is something you plan around.

Why this stack

Deploy is a git push

A merge to main triggers a build, builds emit immutable deploys, the previous deploy stays live until the new one is ready. There is no CI script to maintain, no Docker image to push, no rollback panic at 11 pm. A bad deploy is one click back to the previous commit.

Preview URLs per PR are the killer feature for client review

Every PR gets a unique URL with the full app running on a copy of the database where you point it. Stakeholders click a link and see the change before merge, designers compare side by side, the back-and-forth that used to take five emails becomes a comment thread on the PR.

Edge runtime where it matters, Node where it belongs

Auth checks, locale rewrites, rate limits and personalisation run at the edge for sub-100ms first byte. Stripe webhooks, file uploads and anything that needs the full Node API stays on the Node runtime. The choice is per route, not per app.

ISR with on-demand revalidation

Pages get rendered once, served from the cache at the edge, regenerated when the content actually changes. A webhook from your CMS triggers `revalidatePath` for the exact route; the cache drops, the next request rebuilds. No nightly cron rebuilds, no cache invalidation guesses.

Cost is predictable when you architect for it

The famous Vercel bill is what happens when a team treats it as a magic box. We design the deployment so cost lives in three places (function invocations, bandwidth, image optimisation), with an alerting threshold on each. The bill becomes a budget line, not a quarterly surprise.

What we build with it

Vercel project setup

Environments (production, preview, development), env vars synced, domain configuration, redirects and security headers in `vercel.json`.

Preview URL workflow

GitHub integration with branch protection, preview comments on PRs, deploy summaries posted to Slack or Linear where the team works.

Edge runtime configuration per route

Routes that hit external APIs and read databases run on Node; routes that only personalise headers or do simple rewrites run at the edge.

ISR + on-demand revalidation

Long TTL caches on content pages, webhook-triggered revalidation tied to the CMS or Supabase, `revalidatePath` and `revalidateTag` in Server Actions.

Image optimisation wired correctly

`next/image` with explicit `sizes`, properly tuned `deviceSizes` and `imageSizes`, remote pattern allowlist, AVIF where it pays off.

Edge Config for feature flags

Sub-millisecond reads of feature flags from anywhere in the app, no extra service to manage, gradual rollout patterns wired in.

Cron jobs

Scheduled tasks defined in `vercel.json` with a deduplicating handler, timeouts set per job, alerting on failure.

Log Drains to your observability stack

Function logs streamed to Datadog, Axiom or your destination of choice, structured logging from the application emitting JSON, request IDs traced end-to-end.

Analytics + Speed Insights

Vercel Analytics for page views, Speed Insights for Core Web Vitals per route, both wired to alerts when a release regresses.

Custom domains, redirects, headers

Multi-domain setup for multi-locale, 301 redirects from the old URLs, CSP and HSTS headers configured in `vercel.json`.

Cost monitoring + alerting

Vercel usage API polled daily, alerts set per category (function execution, bandwidth, image optimisation), monthly trend reports.

Migration from Netlify, Heroku, AWS

Existing deploys mapped to Vercel equivalents, env vars migrated, CI/CD pipelines simplified, old infrastructure deprecated with a cutover plan.

A Vercel-native deploy config with edge, ISR and revalidation in one project

A `vercel.json` defining headers, redirects and crons; a `next.config.ts` with image and cache strategy; a route handler that runs at the edge for auth; a Server Action that calls `revalidatePath` when content changes. Four files; covers the production deploy without secrets.

Most "deploy to Vercel" guides stop at hitting the button. Production Vercel is a config you architect. The four files below are what we ship: a vercel.json with headers, redirects and crons; a next.config.ts with the cache strategy spelled out; an edge route that runs auth in under 50ms of first byte; and a Server Action that revalidates the right path when content changes.

1. The vercel.json config

The production config lives in source control. Headers, redirects, crons and rewrites are reviewable, diffable and tied to the commit that introduced them.

// vercel.json
{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Strict-Transport-Security",
          "value": "max-age=63072000; includeSubDomains; preload"
        },
        {
          "key": "X-Content-Type-Options",
          "value": "nosniff"
        },
        {
          "key": "Referrer-Policy",
          "value": "strict-origin-when-cross-origin"
        }
      ]
    },
    {
      "source": "/_next/static/(.*)",
      "headers": [
        {
          "key": "Cache-Control",
          "value": "public, max-age=31536000, immutable"
        }
      ]
    }
  ],
  "redirects": [
    {
      "source": "/blog/old-slug",
      "destination": "/blog/new-slug",
      "permanent": true
    }
  ],
  "crons": [
    {
      "path": "/api/cron/reconcile-stripe",
      "schedule": "0 3 * * *"
    },
    {
      "path": "/api/cron/refresh-sitemap",
      "schedule": "0 4 * * *"
    }
  ]
}

2. The Next.js cache strategy

The next.config.ts makes the cache strategy explicit: ISR for content pages, no caching for authenticated routes, image optimisation tuned to the actual breakpoints the design system uses.

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

const config: NextConfig = {
  images: {
    formats: ['image/avif', 'image/webp'],
    deviceSizes: [360, 640, 768, 1024, 1280, 1536, 1920],
    imageSizes: [16, 32, 48, 64, 96, 128, 256],
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'images.example.com',
        pathname: '/uploads/**',
      },
    ],
  },
  experimental: {
    // Long-lived ISR cache for blog content; bust on demand via webhook.
    isrFlushToDisk: true,
  },
  async headers() {
    return [
      {
        source: '/api/:path*',
        headers: [{ key: 'Cache-Control', value: 'no-store' }],
      },
    ]
  },
}

export default config

3. The edge route

The auth check runs at the edge in milliseconds. The route reads the session cookie, validates it against a JWT public key (no database round-trip), and either rewrites to the application or redirects to login.

// app/api/auth/check/route.ts
import { NextResponse } from 'next/server'
import { jwtVerify } from 'jose'

export const runtime = 'edge'

const PUBLIC_KEY = process.env.SESSION_PUBLIC_KEY!

export async function GET(request: Request) {
  const cookie = request.headers.get('cookie')
  const token = cookie?.match(/session=([^;]+)/)?.[1]
  if (!token) return NextResponse.json({ ok: false }, { status: 401 })

  try {
    const { payload } = await jwtVerify(
      token,
      new TextEncoder().encode(PUBLIC_KEY),
    )
    return NextResponse.json({ ok: true, userId: payload.sub })
  } catch {
    return NextResponse.json({ ok: false }, { status: 401 })
  }
}

4. On-demand revalidation from a Server Action

When a content editor publishes a post, the Server Action writes to the database and calls revalidatePath for the exact route. The cached version drops; the next request rebuilds and re-caches. No nightly rebuild needed.

// app/admin/posts/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { createServerClient } from '@/lib/supabase/server'

export async function publishPost(postId: string): Promise<{ ok: true } | { ok: false; error: string }> {
  const supabase = await createServerClient()

  const { data, error } = await supabase
    .from('posts')
    .update({ status: 'published', published_at: new Date().toISOString() })
    .eq('id', postId)
    .select('slug, locale')
    .single()

  if (error) return { ok: false, error: error.message }

  // Drop the cache for this exact post and the locale-level index.
  revalidatePath(`/${data.locale}/blog/${data.slug}`)
  revalidatePath(`/${data.locale}/blog`)

  return { ok: true }
}

5. What this buys you

The deploy is a git push. The preview is a URL that shows up on the PR. The auth check answers in 30 milliseconds. The blog cache regenerates the moment an editor presses publish, not on a nightly cron. The bill lands where you expect because every cache decision lives in source control where you can audit it.

The Vercel that gets a bad reputation is the one configured by accident. The Vercel that earns its place is the one architected, with intent, three or four files at a time.

Frequently asked questions

Tell us about your Vercel deployment

New deploys and migrations from Netlify, Heroku or AWS both welcome.