docs/plans/2026-01-23-fix-vercel-caching-costs-plan.md
Deepened on: 2026-01-23 Sources: Vercel React Best Practices, 3 reviewer agents (DHH, Kieran, Simplicity)
generateStaticParams issues for CN routesearchParams - must fixuseLocale.ts uses useSearchParams() - needs migration to usePathname()/cn/...?locale=cn) is redundant - remove query params entirelyDocs pages have 0% cache hit rate causing $50+ Vercel overage. Every request hits origin server, running expensive SSR (code highlighting, file tree generation).
Symptoms from Vercel dashboard:
/docs/[[...slug]]Root Cause:
// apps/www/src/app/(app)/docs/[[...slug]]/page.tsx:35-37
type DocPageProps = {
searchParams: Promise<{ locale: string }>; // <-- THIS FORCES DYNAMIC RENDERING
};
Using searchParams in Next.js App Router forces dynamic rendering - pages cannot be statically generated or cached, even with generateStaticParams().
From Vercel's React Best Practices guide (Section 3.3 - Cross-Request LRU Caching):
"React.cache() only works within one request. For data shared across sequential requests, use an LRU cache."
However, for static documentation sites, the better approach is:
generateStaticParams() - already exists but bypassedsearchParams forces dynamic modeThe current React.cache() calls in registry-cache.ts only deduplicate within a single request - they don't help with CDN caching.
Move locale from query param (?locale=cn) to path segment (/cn/docs/...).
Why this approach:
| File | Change |
|---|---|
apps/www/src/app/(app)/docs/[[...slug]]/page.tsx | Remove searchParams, add locale prop |
apps/www/src/app/(app)/page.tsx | Remove searchParams (home page!) |
apps/www/src/app/cn/docs/[[...slug]]/page.tsx | NEW - CN docs route |
apps/www/src/app/cn/page.tsx | NEW - CN home page |
apps/www/src/hooks/useLocale.ts | Use usePathname() not useSearchParams() |
apps/www/src/lib/withLocale.ts | Remove ?locale=cn suffix |
apps/www/src/components/languages-dropdown-menu.tsx | Remove query param setting |
apps/www/next.config.ts | Replace rewrites with redirects |
Remove searchParams from page props:
// apps/www/src/app/(app)/docs/[[...slug]]/page.tsx
// BEFORE
type DocPageProps = {
params: Promise<{ slug: string[] }>;
searchParams: Promise<{ locale: string }>; // DELETE THIS
};
// AFTER
type DocPageProps = {
params: Promise<{ slug: string[] }>;
locale?: "en" | "cn"; // Optional prop, defaults to 'en'
};
export const dynamic = "force-static";
// Update getDocFromParams
async function getDocFromParams({ params, locale = "en" }: DocPageProps) {
const slugParam = (await params).slug;
// Use locale prop instead of searchParams
if (locale === "cn") {
// Chinese logic...
}
// ...
}
// apps/www/src/app/cn/docs/[[...slug]]/page.tsx
import { DocContent } from "@/app/(app)/docs/[[...slug]]/doc-content";
import { allDocs } from "contentlayer/generated";
// ... other imports from main page
export const dynamic = "force-static";
// IMPORTANT: Generate params for CN docs specifically
export function generateStaticParams() {
return allDocs
.filter((doc) => doc._raw.sourceFileName?.endsWith(".cn.mdx"))
.map((doc) => ({
slug: doc.slugAsParams.replace(/\.cn$/, "").split("/").slice(1),
}));
}
export default async function CNDocPage({
params,
}: {
params: Promise<{ slug: string[] }>;
}) {
// Render with locale='cn'
// ... (copy rendering logic with locale hardcoded to 'cn')
}
// apps/www/src/hooks/useLocale.ts
// BEFORE - forces client-side hydration issues
import { useSearchParams } from "next/navigation";
export const useLocale = () => {
const searchParams = useSearchParams();
const locale = searchParams?.get("locale") || "en";
return locale;
};
// AFTER - derive from pathname
import { usePathname } from "next/navigation";
export const useLocale = () => {
const pathname = usePathname();
return pathname?.startsWith("/cn") ? "cn" : "en";
};
// apps/www/src/lib/withLocale.ts
// BEFORE - adds redundant query param
export const hrefWithLocale = (href: string, locale: string) => {
if (locale === "cn") {
return `/cn${href}?locale=${locale}`; // Redundant!
}
return href;
};
// AFTER - path only
export const hrefWithLocale = (href: string, locale: string) => {
if (locale === "cn") {
return `/cn${href}`;
}
return href;
};
// apps/www/next.config.ts
// REMOVE these rewrites:
rewrites: async () => {
return [
{ source: '/cn', destination: '/?locale=cn' }, // DELETE
{ source: '/cn/:path*', destination: '/:path*?locale=cn' }, // DELETE
];
},
// ADD these redirects for old URLs:
redirects: async () => {
return [
// ...existing redirects...
// Redirect old ?locale=cn URLs to /cn/* paths
{
source: '/',
has: [{ type: 'query', key: 'locale', value: 'cn' }],
destination: '/cn',
permanent: true,
},
{
source: '/docs/:path*',
has: [{ type: 'query', key: 'locale', value: 'cn' }],
destination: '/cn/docs/:path*',
permanent: true,
},
];
},
// apps/www/src/app/(app)/page.tsx
// BEFORE
export default async function IndexPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const locale = ((await searchParams).locale || "en") as keyof typeof i18n;
// ...
}
// AFTER - remove searchParams, default to 'en'
export const dynamic = "force-static";
export default async function IndexPage() {
const locale = "en"; // English home page
// ...
}
// Create separate /cn/page.tsx for Chinese home
searchParams from docs page propssearchParams from home page props/cn/docs/[[...slug]]/page.tsx with proper generateStaticParams/cn/page.tsx for Chinese homeuseLocale.ts to use usePathname()withLocale.ts to remove query paramsx-vercel-cache: HIT header after deploy/cn/* without query params?locale=cn URLs| Approach | Pros | Cons |
|---|---|---|
| Path segments (chosen) | Full caching, SEO-friendly | Route restructuring needed |
| Cookies for locale | No URL changes | Still dynamic, no caching |
| ISR with short TTL | Quick fix | Still hits origin frequently |
| Remove CN support | Simplest | Loses Chinese users |
?locale=cn URLs need 301 redirects to preserve SEO