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.
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
- "How Next.js App Router Pages Become Functions (And Cost You Money)" ← Selected
- "The Silent Cost Leak: When Next.js Pages Run as Functions on Every Request"
- "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
- Go to your Vercel project → Analytics → Functions
- Look at the "Top Routes" table
- 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(), ordraftMode() - 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 directsupabase.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 = 86400tells Next.js to rebuild the page every 24 hours- Between revalidations, pages are served as static HTML
- No
cookies(),headers(), ordraftMode()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:
-
Static page renders empty shell. The HTML contains no data. It's just structure—divs, buttons, loading states. There's nothing sensitive to expose.
-
Client component fetches from API route. The client makes an authenticated request to
/api/costbook/creatorwith a JWT token in the Authorization header. -
API route enforces authentication. The route validates the token server-side. No valid token = 401 Unauthorized. The client never sees data without authentication.
-
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.
-
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:
-
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.
-
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.
-
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/creatorafter 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
CostbookCreatorClientcomponent - 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(), ordraftMode()usage found - No
fetch()calls withcache: "no-store"found (blog uses filesystem reads viafs.readFileSync) - SEO metadata preserved
app/blog/[slug]/page.tsx:
- Added
export const revalidate = 86400;for ISR with 24-hour revalidation - No
cookies(),headers(), ordraftMode()usage found - No
fetch()calls withcache: "no-store"found - SEO metadata preserved
generateStaticParamsexists and is ISR-compatible
The One-Line Mistakes That Cost Real Money
These single lines of code can force a page into dynamic mode:
const cookies = cookies();— Reading cookies forces dynamic renderingconst headers = headers();— Reading headers forces dynamic renderingexport const dynamic = "force-dynamic";— Explicitly forces dynamic renderingfetch(url, { cache: "no-store" })— Uncached fetch forces dynamic renderingdraftMode().isEnabled— Draft mode forces dynamic rendering- 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
-
Default to static. Every page should be static unless you have a specific reason for dynamic rendering.
-
Use ISR for content that updates periodically. Blog posts, product listings, etc. Use
export const revalidate = 86400;for 24-hour revalidation. -
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
-
Check Vercel dashboard regularly. Look for routes with unexpected function invocations.
-
Use
export const dynamic = "force-static"when you're sure. Explicitly mark pages that should never be dynamic. -
Avoid
cookies()andheaders()in pages. Move auth checks to API routes or middleware. -
Client-side auth hooks are fine.
useAuth()and similar hooks don't force dynamic rendering. They run in the browser. -
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.
-
Build output tells the truth. Run
next buildand look forλ(lambda) symbols. Those are dynamic routes. -
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.