Back to Journal
Technical
December 21, 2025
10 min read

How Next.js App Router Pages Become Functions (And Cost You Money)

We discovered a silent cost leak: pages that should be static were running as server functions on every visit. Here's how we found it, fixed it, and reduced our Vercel bill.

How Next.js App Router Pages Become Functions (And Cost You Money)

Every high-stakes conversation has a moment where it either moves forward—or quietly breaks.

We discovered a Vercel bill going over limits. We inspected Vercel usage and found that Function Invocations were being charged, and that certain App Router pages were being treated as Functions. We fixed it by converting routes to static pages with client-side data fetching, and using ISR for the blog.

Title Options

  1. "How Next.js App Router Pages Become Functions (And Cost You Money)" ← Selected
  2. "The Silent Cost Leak: When Next.js Pages Run as Functions on Every Request"
  3. "Fixing Vercel Function Invocation Costs: Static vs Dynamic in Next.js App Router"

The Silent Cost Leak

Here's what happened: our Vercel bill started climbing. Not from traffic spikes or new features—steady, unexplained growth in Function Invocations.

We dug into the Vercel dashboard and found something surprising: pages that should have been static HTML were running as server functions on every single visit. Two routes were the main culprits:

  • /apps/best-estimator/costbook/creator/[...segments] — highest invocations
  • /blog — also showing invocations

These weren't API routes. They were regular pages. But Next.js was treating them as dynamic server functions, executing code on every request.

This article explains what happened, how we fixed it, and how to avoid the same mistake.


The Mental Model: Static vs Dynamic vs API Routes

In Next.js App Router, there are three ways a route can execute:

1. Static Generation (SSG)

  • Page is built once at build time
  • Served as static HTML from CDN
  • Zero function invocations
  • Fast, cheap, scales infinitely

2. Incremental Static Regeneration (ISR)

  • Page is built at build time, then revalidated on a schedule
  • Served as static HTML between revalidations
  • Function runs only during revalidation (e.g., every 24 hours)
  • Fast, cheap, updates periodically

3. Dynamic Server Rendering (SSR)

  • Page runs as a server function on every request
  • Executes server code, fetches data, renders HTML
  • Each visit = one function invocation
  • Slower, costs money, scales with traffic

4. API Routes

  • Always run as server functions
  • Expected to be dynamic
  • This is fine—they're meant to be functions

The problem: pages that should be static or ISR were being forced into dynamic SSR mode.


How This Quietly Explodes (A Realistic Scenario)

Here's a realistic scenario that shows why this matters even at modest traffic levels.

You have one authenticated dashboard page—a costbook creator or project management interface. It's used by your team and a few clients. Nothing viral, nothing high-traffic.

The setup:

  • One page rendered as a server function
  • 500 daily visits (team members, clients, internal navigation)
  • Each visit triggers one function invocation

The math:

  • 500 visits/day × 30 days = 15,000 invocations/month
  • That's one page. If you have three similar pages: 45,000/month
  • Five pages: 75,000/month

But here's what compounds it:

Bots and crawlers hit your pages. Googlebot, monitoring services, uptime checkers. They don't need to run server functions, but they do.

Page refreshes count as new visits. A user refreshing to see updates? That's another invocation.

Navigation within the app can trigger re-renders. Client-side navigation should be instant, but if the page is dynamic, Next.js may still hit the server.

The realistic total:

  • 500 "real" visits/day
  • 100 bot/crawler hits/day
  • 200 refreshes/navigation events/day
  • Total: ~800 requests/day = 24,000 invocations/month for one page

This isn't high traffic. This is normal usage for a small SaaS app. And it's all unnecessary if the page should be static.

Traffic doesn't need to be high for this to matter. The pattern compounds across pages, and every unnecessary invocation is waste.


How This Explodes

Let's do the math.

Say you have a dashboard page that should be static but is running as a function. It gets 1,000 visits per day.

  • Static: 0 function invocations = $0
  • Dynamic: 1,000 invocations/day = 30,000/month

On Vercel's Pro plan ($20/month), you get 1 million function invocations included. That's fine. But if you have multiple pages making this mistake, or if traffic grows:

  • 5 pages × 1,000 visits/day = 5,000 invocations/day = 150,000/month
  • 10 pages × 2,000 visits/day = 20,000 invocations/day = 600,000/month
  • 20 pages × 5,000 visits/day = 100,000 invocations/day = 3,000,000/month

At 3 million invocations, you're paying $0.40 per million over the included 1M = $0.80/month. Not huge, but it's waste. And if you're on Hobby (100K included), you hit overages faster.

The issue: you're paying for server compute when you don't need it. Static pages are faster, cheaper, and scale better.


How to Diagnose

Step 1: Check Vercel Dashboard

  1. Go to your Vercel project → Analytics → Functions
  2. Look at the "Top Routes" table
  3. Any route with high invocations that isn't an API route is suspicious

Step 2: Identify the Pattern

Routes that show up here are running as functions. Common culprits:

  • Pages using cookies(), headers(), or draftMode()
  • Pages with export const dynamic = "force-dynamic"
  • Pages with cache: "no-store" in fetch calls
  • Pages calling server-only APIs during render

Step 3: Check Build Output

Run next build and look at the output. Routes marked as λ (lambda) are dynamic. Routes marked as (static) are static.

Route (app)                              Size     First Load JS
┌ ○ /                                   5.2 kB         85.3 kB
├ ○ /about                              2.1 kB         82.2 kB
├ λ /apps/best-estimator/.../creator   12.4 kB        102.1 kB  ← Problem!
└ ○ /blog                               8.1 kB         88.2 kB

Before vs After Architecture

Before (Dynamic SSR - Runs on Every Request)

User Request
    ↓
Next.js Server Function
    ↓
Read cookies() / headers()
    ↓
Call Supabase (server-side)
    ↓
Render HTML
    ↓
Send to User
    ↓
[Every visit = 1 function invocation]

Code Pattern (Problematic):

// app/apps/best-estimator/costbook/creator/[...segments]/page.tsx
import { cookies } from "next/headers";
import { createServerClient } from "@/lib/supabaseServer";

export default async function CostbookCreatorPage() {
  // ❌ This forces dynamic rendering
  const cookieStore = cookies();
  
  // ❌ Server-side data fetching
  const supabase = createServerClient();
  const { data } = await supabase.from("costbook_items").select();
  
  return <div>{/* render data */}</div>;
}

After (Static Shell + Client Fetch)

Build Time
    ↓
Generate Static HTML Shell
    ↓
Deploy to CDN
    ↓
User Request
    ↓
Serve Static HTML (0 invocations)
    ↓
Client Hydrates
    ↓
Client Fetches from API Route
    ↓
[Page load = 0 function invocations]
[API call = 1 invocation, but only when data changes]

Code Pattern (Fixed):

// app/apps/best-estimator/costbook/creator/[...segments]/page.tsx
import CostbookCreatorClient from "./CostbookCreatorClient";

// ✅ Force static generation
export const dynamic = "force-static";

export default function CostbookCreatorPage() {
  // ✅ Just render the client component
  return <CostbookCreatorClient />;
}
// CostbookCreatorClient.tsx
"use client";

import { useEffect, useState } from "react";
import { useAuth } from "@/app/components/AuthProvider";

export default function CostbookCreatorClient() {
  const { session, loading: authLoading } = useAuth();
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    if (!authLoading && session) {
      // ✅ Fetch from API route after mount
      fetch("/api/costbook/creator", {
        headers: {
          Authorization: `Bearer ${session.access_token}`,
        },
      })
        .then((res) => res.json())
        .then(setData)
        .finally(() => setLoading(false));
    }
  }, [authLoading, session]);

  if (loading) return <LoadingSkeleton />;
  return <div>{/* render data */}</div>;
}

The Problematic Pattern

Here's what was causing the issue in our costbook creator route:

// ❌ BEFORE: Server page with dynamic patterns
import { cookies } from "next/headers";
import { createServerClient } from "@/lib/supabaseServer";

export default async function Page() {
  // Forces dynamic rendering
  const cookieStore = cookies();
  
  // Server-side Supabase call
  const supabase = createServerClient();
  const { data } = await supabase
    .from("costbook_items")
    .select();
  
  return <div>{/* ... */}</div>;
}

Why this causes problems:

  • cookies() forces Next.js to render on every request
  • Server-side data fetching prevents static generation
  • Every visit runs server code = function invocation

The Fixed Page (Static Shell)

// ✅ AFTER: Static server component
import CostbookCreatorClient from "./CostbookCreatorClient";

// Explicitly force static generation
export const dynamic = "force-static";

export default function CostbookCreatorPage() {
  // No server-side code, no cookies(), no headers()
  return <CostbookCreatorClient />;
}

Why this works:

  • No dynamic server patterns
  • Renders at build time
  • Served as static HTML
  • Zero function invocations for page load

The Client Component (Data Fetching)

// ✅ CostbookCreatorClient.tsx
"use client";

import { useState, useEffect, useCallback } from "react";
import { useRouter, useParams } from "next/navigation";
import { useAuth } from "@/app/components/AuthProvider";

export default function CostbookCreatorClient() {
  const router = useRouter();
  const params = useParams();
  const { user, session, loading: authLoading } = useAuth();
  
  const [loading, setLoading] = useState(true);
  const [folders, setFolders] = useState<string[]>([]);
  const [items, setItems] = useState<CostbookItem[]>([]);
  const [error, setError] = useState<string | null>(null);

  // ✅ Fetch data from API after mount
  const loadData = useCallback(async () => {
    if (!session || !user) {
      router.push("/auth/login");
      return;
    }

    try {
      setLoading(true);
      setError(null);

      const queryParams = new URLSearchParams();
      // Build query params from route segments...

      const response = await fetch(`/api/costbook/creator?${queryParams}`, {
        headers: {
          Authorization: `Bearer ${session.access_token}`,
        },
      });

      if (!response.ok) {
        const data = await response.json();
        setError(data.error || "Failed to load costbook data");
        return;
      }

      const data = await response.json();
      setFolders(data.folders);
      setItems(data.items);
    } catch (err) {
      console.error("Error loading data:", err);
      setError("An unexpected error occurred");
    } finally {
      setLoading(false);
    }
  }, [router, session, user, /* query params */]);

  useEffect(() => {
    if (!authLoading && session && user) {
      loadData();
    }
  }, [loadData, authLoading, session, user]);

  // ✅ Show loading skeleton while fetching
  if (authLoading || loading) {
    return (
      <div className="min-h-screen bg-slate-950">
        <div className="max-w-6xl mx-auto px-4 py-8">
          <div className="space-y-4">
            <div className="h-8 bg-slate-800 rounded w-1/3 animate-pulse"></div>
            <div className="h-4 bg-slate-800 rounded w-1/2 animate-pulse"></div>
            {/* More skeleton items... */}
          </div>
        </div>
      </div>
    );
  }

  if (error) {
    return <div className="text-red-400">{error}</div>;
  }

  return (
    <div>
      {/* Render folders and items */}
    </div>
  );
}

Key points:

  • Uses useAuth() hook instead of direct supabase.auth.getSession()
  • Fetches data from API route after mount
  • Shows loading skeleton during fetch
  • Handles error states
  • All UI logic in client component

The API Route (Protected with Auth)

// ✅ app/api/costbook/creator/route.ts
import { NextResponse } from 'next/server';
import { createServerClient, getAuthenticatedUser } from '@/lib/supabaseServer';

export async function GET(req: Request) {
  try {
    // ✅ Enforce authentication
    const user = await getAuthenticatedUser(req);
    
    if (!user) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    const supabase = createServerClient();
    const contractorId = await getContractorId(user.id, supabase);

    // Parse query params
    const url = new URL(req.url);
    const g1 = decodeURIComponent(url.searchParams.get('g1') || '');
    // ... parse g2, g3, g4, g5

    // ✅ Query with RLS (Row Level Security) enforced
    const { data: folders } = await supabase
      .from('costbook_folders')
      .select('folder_name')
      .eq('contractor_id', contractorId)
      .order('sort_order', { ascending: true });

    const { data: items } = await supabase
      .from('costbook_items')
      .select('*')
      .eq('contractor_id', contractorId)
      .order('sort_order', { ascending: true });

    return NextResponse.json({ folders, items });
  } catch (err) {
    console.error('Error in GET /api/costbook/creator:', err);
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
  }
}

Why this is safe:

  • Authentication is enforced at the API boundary
  • RLS policies in Supabase ensure users only see their data
  • API route runs as a function (expected), but only when data is fetched
  • Much fewer invocations than running on every page load

Blog ISR Implementation

For the blog, we converted it to ISR (Incremental Static Regeneration):

// ✅ app/blog/page.tsx
import { getAllPosts, getPostsByCategory } from "@/lib/blog";

// ✅ ISR: Revalidate every 24 hours
export const revalidate = 86400; // 24 hours in seconds

export default async function BlogPage({ searchParams }) {
  const selectedCategory = searchParams.category || "All";
  
  // ✅ Reads from filesystem (stable, no dynamic patterns)
  const posts = selectedCategory === "All"
    ? getAllPosts()
    : getPostsByCategory(selectedCategory);

  return (
    <div>
      {/* Render posts */}
    </div>
  );
}
// ✅ app/blog/[slug]/page.tsx
import { getPostBySlug, getAllPosts } from "@/lib/blog";

// ✅ ISR: Revalidate every 24 hours
export const revalidate = 86400;

// ✅ Generate static params at build time
export async function generateStaticParams() {
  const posts = getAllPosts();
  return posts.map((post) => ({
    slug: post.slug,
  }));
}

export default async function BlogPostPage({ params }) {
  const { slug } = params;
  const post = getPostBySlug(slug);
  
  if (!post) {
    notFound();
  }

  return (
    <div>
      {/* Render post */}
    </div>
  );
}

Why this works:

  • revalidate = 86400 tells Next.js to rebuild the page every 24 hours
  • Between revalidations, pages are served as static HTML
  • No cookies(), headers(), or draftMode() usage
  • Reads from filesystem via fs.readFileSync (stable, deterministic)
  • Function runs only during revalidation, not on every visit

Supabase Safety: This Doesn't Weaken Security

Question: "If we move auth checks to the client, doesn't that weaken security?"

Answer: No. Static pages do not expose data. Client components do not bypass RLS. All sensitive access still happens in API routes.

Here's the explicit breakdown:

Rendering Location ≠ Data Access Control

Where you render HTML has nothing to do with data security. Security is enforced at the data access layer, not the rendering layer.

What actually happens:

  1. Static page renders empty shell. The HTML contains no data. It's just structure—divs, buttons, loading states. There's nothing sensitive to expose.

  2. Client component fetches from API route. The client makes an authenticated request to /api/costbook/creator with a JWT token in the Authorization header.

  3. API route enforces authentication. The route validates the token server-side. No valid token = 401 Unauthorized. The client never sees data without authentication.

  4. RLS policies run on every query. Supabase RLS policies execute at the database level, regardless of whether the query comes from a server page, API route, or direct client. Users can only see rows they're allowed to see based on RLS rules.

  5. Tokens are validated server-side. The API route validates the JWT token from the Authorization header. Invalid, expired, or missing tokens are rejected before any database query runs.

The security model:

  • Static Page: Renders empty HTML shell (no data)
  • Client Component: Makes authenticated API requests (requires valid token)
  • API Route: Enforces authentication, validates tokens, queries database
  • Database: Enforces RLS policies on every query

This is the recommended pattern for authenticated apps in Next.js App Router. It's secure because data access is controlled at the API boundary, not at the rendering boundary.

What Would Actually Be Unsafe

If you're concerned about security, here's what would actually be unsafe:

  1. Using anon keys for privileged queries. If you used Supabase's anon key in the client and disabled RLS, users could query any data. This is unsafe. We don't do this.

  2. Disabling RLS. If you turned off Row Level Security policies, users could access data they shouldn't. This is unsafe. We don't do this.

  3. Exposing service role keys. If you put Supabase service role keys in client-side code, anyone could bypass all security. This is unsafe. We don't do this.

What we do instead:

  • Use authenticated API routes that validate JWT tokens
  • Keep RLS policies enabled and enforced
  • Never expose service role keys
  • All database queries run server-side with proper authentication

The architecture is secure. Rendering static HTML doesn't weaken security—it just moves data fetching to the right place.


What We Changed (Real Fix)

Costbook Creator Route

Created CostbookCreatorClient.tsx — a client component that:

  • Fetches data from /api/costbook/creator after mount
  • Shows a loading skeleton with shimmer animation
  • Handles error states
  • Contains all the UI logic from the original page

Updated page.tsx to be a static server component:

  • Removed all client-side code and imports
  • Added export const dynamic = "force-static" to force static generation
  • Renders only the CostbookCreatorClient component
  • No server-side data fetching (no cookies(), headers(), or server fetches)

Updated CostbookCreatorClient.tsx to use the useAuth() hook instead of direct supabase.auth.getSession() calls:

  • Removed direct Supabase client import
  • Added useAuth() import from @/app/components/AuthProvider
  • Replaced all supabase.auth.getSession() calls with the hook
  • Updated getToken() to use the session from the hook
  • Added proper auth loading state handling
  • Updated loading skeleton to account for auth loading

Blog

app/blog/page.tsx:

  • Added export const revalidate = 86400; for ISR with 24-hour revalidation
  • No cookies(), headers(), or draftMode() usage found
  • No fetch() calls with cache: "no-store" found (blog uses filesystem reads via fs.readFileSync)
  • SEO metadata preserved

app/blog/[slug]/page.tsx:

  • Added export const revalidate = 86400; for ISR with 24-hour revalidation
  • No cookies(), headers(), or draftMode() usage found
  • No fetch() calls with cache: "no-store" found
  • SEO metadata preserved
  • generateStaticParams exists and is ISR-compatible

The One-Line Mistakes That Cost Real Money

These single lines of code can force a page into dynamic mode:

  1. const cookies = cookies(); — Reading cookies forces dynamic rendering
  2. const headers = headers(); — Reading headers forces dynamic rendering
  3. export const dynamic = "force-dynamic"; — Explicitly forces dynamic rendering
  4. fetch(url, { cache: "no-store" }) — Uncached fetch forces dynamic rendering
  5. draftMode().isEnabled — Draft mode forces dynamic rendering
  6. Server actions called during render — Can force dynamic rendering

The fix: Move these patterns to API routes or client components. Keep pages static unless you truly need per-request rendering.


Lessons Learned / Playbook

  1. Default to static. Every page should be static unless you have a specific reason for dynamic rendering.

  2. Use ISR for content that updates periodically. Blog posts, product listings, etc. Use export const revalidate = 86400; for 24-hour revalidation.

  3. Authenticated apps: static shell + client fetch + API routes.

    • Page: Static HTML shell
    • Client component: Fetches data after mount
    • API route: Enforces auth, queries database, returns data
  4. Check Vercel dashboard regularly. Look for routes with unexpected function invocations.

  5. Use export const dynamic = "force-static" when you're sure. Explicitly mark pages that should never be dynamic.

  6. Avoid cookies() and headers() in pages. Move auth checks to API routes or middleware.

  7. Client-side auth hooks are fine. useAuth() and similar hooks don't force dynamic rendering. They run in the browser.

  8. RLS doesn't care where the query comes from. Supabase RLS policies work the same whether you query from a server page or an API route.

  9. Build output tells the truth. Run next build and look for λ (lambda) symbols. Those are dynamic routes.

  10. Test in production. Development mode doesn't always reflect production behavior. Check actual function invocations in Vercel.


Copy/Paste Audit Checklist

Use this checklist to audit your Next.js App Router routes:

# 1. Check Vercel Dashboard
- [ ] Go to Analytics → Functions
- [ ] Identify routes with high invocations
- [ ] Note any non-API routes in the list

# 2. Check Build Output
- [ ] Run `next build`
- [ ] Look for `λ` (lambda) symbols in route list
- [ ] Identify routes that should be static but are dynamic

# 3. Code Audit (for each route)
- [ ] No `cookies()` calls
- [ ] No `headers()` calls
- [ ] No `draftMode()` usage
- [ ] No `export const dynamic = "force-dynamic"`
- [ ] No `fetch()` with `cache: "no-store"`
- [ ] No server actions called during render
- [ ] No direct Supabase queries in server components (move to API routes)

# 4. Fix Pattern
- [ ] Convert server page to static shell
- [ ] Create client component for data fetching
- [ ] Create API route for data access
- [ ] Use `useAuth()` hook in client component
- [ ] Add `export const dynamic = "force-static"` if needed
- [ ] For content pages, add `export const revalidate = 86400;` for ISR

# 5. Verify
- [ ] Run `next build` again
- [ ] Confirm route shows as `○` (static) or ISR
- [ ] Deploy and check Vercel dashboard
- [ ] Confirm function invocations dropped

Result

After implementing these changes:

  • Costbook creator route: Went from dynamic SSR to static shell + client fetch
  • Blog routes: Converted to ISR with 24-hour revalidation
  • Function invocations: Dropped significantly (exact numbers depend on traffic)
  • Vercel bill: Reduced function invocation costs
  • Page load speed: Improved (static HTML serves faster than server-rendered)
  • Security: Unchanged (auth and RLS still enforced at API boundary)

The pages now run as static HTML served from CDN, with data fetched client-side from protected API routes. This is the recommended architecture for authenticated apps in Next.js App Router.


Summary

In Next.js App Router, pages become functions when they use cookies(), headers(), draftMode(), force-dynamic, or uncached fetch patterns. This causes every page visit to trigger a server function invocation, increasing costs and slowing down page loads.

The fix: convert pages to static shells that render client components, which fetch data from API routes after mount. For content pages, use ISR with export const revalidate = 86400; to update periodically without running on every request.

This architecture maintains security (auth and RLS enforced at API boundary), improves performance (static HTML from CDN), and reduces costs (fewer function invocations).

Check your Vercel dashboard. If you see non-API routes with high function invocations, audit them using the checklist above. The fix is usually straightforward: static shell + client component + API route.


Pre-Deploy Cost Safety Checklist (Copy/Paste)

Use this checklist before deploying any new route or page:

# Route Classification
- [ ] Is this route marketing/public content? → Should be static or ISR
- [ ] Is this route app UI (dashboard, settings, etc.)? → Should be static shell + client fetch
- [ ] Is this route an API endpoint? → Function invocation is expected

# Dynamic Pattern Check
- [ ] Does this page use cookies(), headers(), or draftMode()? → Move to API route or client component
- [ ] Does this page have export const dynamic = "force-dynamic"? → Verify this is intentional
- [ ] Does this page use fetch() with cache: "no-store"? → Move to API route or use ISR

# Rendering Strategy
- [ ] Should this page be static? → Add export const dynamic = "force-static"
- [ ] Should this page be ISR? → Add export const revalidate = 86400 (or appropriate interval)
- [ ] Does this page need per-request rendering? → Only if truly necessary (e.g., A/B testing, personalization)

# Data Access
- [ ] Are Supabase queries only in API routes? → Never in server page components
- [ ] Are API routes protected with authentication? → All sensitive endpoints require auth
- [ ] Are RLS policies enabled? → Verify in Supabase dashboard

# API Route Safety
- [ ] Are public API routes rate-limited? → Prevent abuse and cost spikes
- [ ] Are bots blocked from internal app routes? → Use middleware or robots.txt
- [ ] Are API routes returning only necessary data? → Minimize payload size

# Build Verification
- [ ] Run next build and check route symbols
- [ ] Verify routes show as ○ (static) not λ (dynamic) unless intentional
- [ ] Check Vercel dashboard after deploy for unexpected invocations

Quick decision tree:

Is this a page route?
├─ Yes → Should it be static?
│  ├─ Yes → Add export const dynamic = "force-static"
│  └─ No → Should it be ISR?
│     ├─ Yes → Add export const revalidate = 86400
│     └─ No → Verify dynamic rendering is necessary
│
└─ No → Is this an API route?
   └─ Yes → Function invocation is expected
      └─ Add rate limiting for public endpoints

Copy this checklist and run it for every route before deployment.

Why We Write About This

We build software for people who rely on it to do real work. Sharing how we think about stability, judgment, and systems is part of building that trust.

Related Reading