dongzhuoyao

vercel-cost-optimization

Use when deploying Next.js apps to Vercel and costs are high, or when setting up a new Vercel project. Covers ISR-breaking patterns, function constraints, caching, Fluid Compute, build optimization. Triggers: "Vercel bill", "Vercel cost", "ISR broken", "dynamic rendering", "cache-control private", "x-vercel-cache MISS", "function invocations", "Fluid Compute", "GB-hours", "s-maxage", "stale-while-revalidate", "maxDuration", "build minutes"

dongzhuoyao 11 1 Updated 3mo ago
GitHub

Install

npx skillscat add dongzhuoyao/tao-research-skills/vercel-cost-optimization

Install via the SkillsCat registry.

SKILL.md

Vercel Cost Optimization

When to Use

  • Vercel bill is higher than expected
  • All pages show cache-control: private, no-cache, no-store
  • x-vercel-cache: MISS on every request
  • Setting up a new Next.js + Vercel project
  • Reviewing deployment config before going live
  • Investigating why ISR/SSG isn't working

Quick Diagnosis

# Check if ISR is working (run twice, second should be HIT)
curl -sI https://your-site.com/ | grep -i 'cache-control\|x-vercel-cache'

# Healthy:
#   cache-control: s-maxage=300, stale-while-revalidate=60
#   x-vercel-cache: HIT

# Broken:
#   cache-control: private, no-cache, no-store, max-age=0, must-revalidate
#   x-vercel-cache: MISS

If every page returns private, no-cache, something is forcing dynamic rendering.

ISR-Breaking Patterns (Most Expensive)

Pattern 1: cookies() or headers() in Shared Code

Calling cookies() or headers() from next/headers anywhere in the rendering tree forces the entire page to be dynamic. This includes root layouts, shared components, and i18n config.

// BAD: Forces EVERY page dynamic (root layout runs for all pages)
// src/app/layout.tsx
export default async function RootLayout({ children }) {
  const locale = await getLocale(); // internally calls cookies()
  return <html lang={locale}>{children}</html>;
}

// BAD: i18n config that reads cookies
// src/i18n/request.ts
export default getRequestConfig(async () => {
  const cookieStore = await cookies();  // FORCES ALL PAGES DYNAMIC
  const locale = cookieStore.get("NEXT_LOCALE")?.value || "en";
  return { locale, messages: ... };
});

Fix: Use the [locale] route segment with next-intl/middleware's createMiddleware, which passes locale via request context (ISR-compatible) instead of reading cookies at render time.

// GOOD: Middleware handles locale detection (ISR-compatible)
// src/middleware.ts
import createMiddleware from "next-intl/middleware";
import { routing } from "@/i18n/routing";
export default createMiddleware(routing);

// GOOD: request.ts uses requestLocale (set by middleware)
// src/i18n/request.ts
export default getRequestConfig(async ({ requestLocale }) => {
  const locale = (await requestLocale) || "en";
  return { locale, messages: ... };
});

// GOOD: Layout uses setRequestLocale for static rendering
// src/app/[locale]/layout.tsx
export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}
export default async function Layout({ params }) {
  const { locale } = await params;
  setRequestLocale(locale);  // Enables ISR
  // ...
}

Important: createMiddleware requires pages under a [locale] folder. Without it, the middleware rewrites to paths that don't exist → 404 on all pages.

Pattern 2: Custom Middleware with NextResponse.rewrite()

// BAD: Cookie/header reads + rewrite breaks ISR
export async function middleware(request) {
  const locale = request.cookies.get("NEXT_LOCALE")?.value;
  const url = new URL(request.url);
  url.pathname = `/${locale}${url.pathname}`;
  return NextResponse.rewrite(url);  // Forces dynamic
}

// GOOD: Use library middleware or simple NextResponse.next()
export async function middleware(request) {
  // Only do redirects, no rewrites
  if (request.nextUrl.pathname.startsWith("/old-path")) {
    return NextResponse.redirect(new URL("/new-path", request.url), 308);
  }
  return NextResponse.next();
}

Pattern 3: Missing setRequestLocale() in Layouts

Even with [locale] routing, forgetting setRequestLocale() keeps pages dynamic:

// BAD: No setRequestLocale
export default async function Layout({ params }) {
  const { locale } = await params;
  const messages = await getMessages();  // Dynamic without setRequestLocale
  return <>{children}</>;
}

// GOOD: Enable static rendering
export default async function Layout({ params }) {
  const { locale } = await params;
  setRequestLocale(locale);  // Tell Next.js this can be static
  const messages = await getMessages();
  return <>{children}</>;
}

Function Constraints (Free Insurance)

vercel.json Configuration

Always set memory and duration caps to prevent runaway costs:

{
  "functions": {
    "src/app/api/heavy-route/route.ts": {
      "memory": 512,
      "maxDuration": 60
    },
    "src/app/api/light-route/route.ts": {
      "memory": 128,
      "maxDuration": 10
    }
  }
}

Route-Level maxDuration Exports

Belt-and-suspenders approach — add to every API route file:

// Light routes (DB reads, simple logic)
export const maxDuration = 10;

// Medium routes (multiple DB queries, external API calls)
export const maxDuration = 15;

// Heavy routes (batch processing, cron jobs)
export const maxDuration = 60;

Memory Guidelines

Route Type Memory Examples
Stubs / simple JSON 128 MB Health check, feature flags, disabled endpoints
DB reads / external API 256 MB List pages, detail pages, search
Heavy processing 512 MB Cron jobs, batch operations, email sending
Never use 1024 MB Default if unset — wasteful for most routes

Cache-Control Headers on API Routes

// Cacheable GET routes (read-only data)
return NextResponse.json(data, {
  headers: {
    "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=600",
  },
});

// Static/rarely-changing data (templates, configs)
return NextResponse.json(data, {
  headers: {
    "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=3600",
  },
});

// Never cache: auth routes, mutations, user-specific data
// (default behavior, no header needed)

Fluid Compute

Disable unless you specifically need it. Fluid Compute adds two line items:

  • Fluid Active CPU
  • Fluid Provisioned Memory

For typical Next.js sites with ISR pages and short API calls, Fluid Compute adds $50-80/month with no benefit. It's designed for sustained workloads (streaming, long-running connections).

Where to disable: Vercel Dashboard → Project Settings → Functions → Fluid Compute toggle.

Build Optimization

Skip Non-Source Builds

{
  "git": {
    "ignoredBuildStep": "git diff --quiet HEAD^ -- src/ prisma/ package.json vercel.json next.config.ts"
  }
}

This skips builds when only docs, scripts, or config files change — saves build minutes.

Skip Static Generation During Build

{
  "env": {
    "SKIP_BUILD_STATIC_GENERATION": "true"
  }
}

Use fallback: 'blocking' or ISR to generate pages on-demand instead of at build time.

Cost Impact Reference

Issue Monthly Cost Impact Fix Difficulty
cookies()/headers() in root layout +$80-100 Medium (requires [locale] refactor)
Fluid Compute enabled unnecessarily +$50-80 Easy (dashboard toggle)
No ignoredBuildStep +$15-30 Easy (vercel.json)
No function memory/duration caps +$10-20 Easy (vercel.json + exports)
No Cache-Control on API routes +$5-15 Easy (response headers)
Default 1024MB function memory +$5-10 Easy (vercel.json)

Anti-Patterns

Anti-Pattern Why It's Bad Fix
Reading cookies() in root layout All pages become dynamic Use [locale] segment + createMiddleware
NextResponse.rewrite() in middleware Breaks ISR for rewritten paths Use NextResponse.next() or library middleware
Using createMiddleware without [locale] folder 404 on all pages Add [locale] route segment first
Removing cookies() without alternative locale detection Breaks language switching Need full i18n architecture change, not just deletion
No maxDuration on API routes Stuck queries run for 60s at 1024MB Add export const maxDuration to every route
Fluid Compute for simple sites $50-80/mo for no benefit Disable in dashboard

Verification Checklist

After deploying optimizations:

# 1. Check ISR is working (hit twice)
curl -sI https://site.com/ | grep -i cache-control
curl -sI https://site.com/ | grep -i x-vercel-cache
# Second request should show HIT

# 2. Check API caching
curl -sI https://site.com/api/your-route | grep -i cache-control
# Should show s-maxage

# 3. Check no 404s/500s
for path in / /papers /topics /authors; do
  echo "$path: $(curl -s -o /dev/null -w '%{http_code}' https://site.com$path)"
done

# 4. Check language switching still works (if applicable)
curl -sI --cookie "NEXT_LOCALE=zh" https://site.com/ | grep -i 'content-language\|set-cookie'

See Also