|
| 1 | +--- |
| 2 | +title: Fix Vercel Caching - $50+ Monthly Overage |
| 3 | +type: fix |
| 4 | +date: 2026-01-23 |
| 5 | +deepened: 2026-01-23 |
| 6 | +--- |
| 7 | + |
| 8 | +# Fix Vercel Caching - $50+ Monthly Overage |
| 9 | + |
| 10 | +## Enhancement Summary |
| 11 | + |
| 12 | +**Deepened on:** 2026-01-23 |
| 13 | +**Sources:** Vercel React Best Practices, 3 reviewer agents (DHH, Kieran, Simplicity) |
| 14 | + |
| 15 | +### Key Improvements |
| 16 | +1. Expanded scope to include ALL locale-dependent files (18 files, not 3) |
| 17 | +2. Added Vercel-specific caching guidance (LRU vs React.cache) |
| 18 | +3. Fixed `generateStaticParams` issues for CN route |
| 19 | +4. Added client-side utility migrations |
| 20 | + |
| 21 | +### Critical Findings from Reviewers |
| 22 | +- Home page ALSO uses `searchParams` - must fix |
| 23 | +- `useLocale.ts` uses `useSearchParams()` - needs migration to `usePathname()` |
| 24 | +- Dual encoding (`/cn/...?locale=cn`) is redundant - remove query params entirely |
| 25 | + |
| 26 | +--- |
| 27 | + |
| 28 | +## Overview |
| 29 | + |
| 30 | +Docs pages have **0% cache hit rate** causing $50+ Vercel overage. Every request hits origin server, running expensive SSR (code highlighting, file tree generation). |
| 31 | + |
| 32 | +## Problem Statement |
| 33 | + |
| 34 | +**Symptoms from Vercel dashboard:** |
| 35 | +- 0% cache hit rate on `/docs/[[...slug]]` |
| 36 | +- $22.84 Fluid Active CPU (178 hours) |
| 37 | +- $20.43 Fast Origin Transfer (338 GB) |
| 38 | +- 99.5% traffic from single region (iad1) |
| 39 | +- Spike started ~Jan 7 |
| 40 | + |
| 41 | +**Root Cause:** |
| 42 | +```tsx |
| 43 | +// apps/www/src/app/(app)/docs/[[...slug]]/page.tsx:35-37 |
| 44 | +type DocPageProps = { |
| 45 | + searchParams: Promise<{ locale: string }>; // <-- THIS FORCES DYNAMIC RENDERING |
| 46 | +}; |
| 47 | +``` |
| 48 | + |
| 49 | +Using `searchParams` in Next.js App Router **forces dynamic rendering** - pages cannot be statically generated or cached, even with `generateStaticParams()`. |
| 50 | + |
| 51 | +### Vercel Best Practices Context |
| 52 | + |
| 53 | +From Vercel's React Best Practices guide (Section 3.3 - Cross-Request LRU Caching): |
| 54 | + |
| 55 | +> "React.cache() only works within one request. For data shared across sequential requests, use an LRU cache." |
| 56 | +
|
| 57 | +However, for **static documentation sites**, the better approach is: |
| 58 | +1. **Static Generation** via `generateStaticParams()` - already exists but bypassed |
| 59 | +2. **Remove dynamic triggers** - `searchParams` forces dynamic mode |
| 60 | +3. **Vercel Edge CDN** will cache static pages automatically |
| 61 | + |
| 62 | +The current `React.cache()` calls in [registry-cache.ts](apps/www/src/lib/registry-cache.ts) only deduplicate within a single request - they don't help with CDN caching. |
| 63 | + |
| 64 | +## Proposed Solution |
| 65 | + |
| 66 | +Move locale from query param (`?locale=cn`) to path segment (`/cn/docs/...`). |
| 67 | + |
| 68 | +**Why this approach:** |
| 69 | +- Enables full static generation + CDN caching |
| 70 | +- Better SEO (separate URLs for each locale) |
| 71 | +- Follows Next.js i18n best practices |
| 72 | +- No runtime locale detection overhead |
| 73 | + |
| 74 | +## Technical Approach |
| 75 | + |
| 76 | +### Files to Modify (Complete List) |
| 77 | + |
| 78 | +| File | Change | |
| 79 | +|------|--------| |
| 80 | +| `apps/www/src/app/(app)/docs/[[...slug]]/page.tsx` | Remove `searchParams`, add locale prop | |
| 81 | +| `apps/www/src/app/(app)/page.tsx` | Remove `searchParams` (home page!) | |
| 82 | +| `apps/www/src/app/cn/docs/[[...slug]]/page.tsx` | NEW - CN docs route | |
| 83 | +| `apps/www/src/app/cn/page.tsx` | NEW - CN home page | |
| 84 | +| `apps/www/src/hooks/useLocale.ts` | Use `usePathname()` not `useSearchParams()` | |
| 85 | +| `apps/www/src/lib/withLocale.ts` | Remove `?locale=cn` suffix | |
| 86 | +| `apps/www/src/components/languages-dropdown-menu.tsx` | Remove query param setting | |
| 87 | +| `apps/www/next.config.ts` | Replace rewrites with redirects | |
| 88 | + |
| 89 | +### Step 1: Fix English Docs Page |
| 90 | + |
| 91 | +Remove `searchParams` from page props: |
| 92 | + |
| 93 | +```tsx |
| 94 | +// apps/www/src/app/(app)/docs/[[...slug]]/page.tsx |
| 95 | + |
| 96 | +// BEFORE |
| 97 | +type DocPageProps = { |
| 98 | + params: Promise<{ slug: string[] }>; |
| 99 | + searchParams: Promise<{ locale: string }>; // DELETE THIS |
| 100 | +}; |
| 101 | + |
| 102 | +// AFTER |
| 103 | +type DocPageProps = { |
| 104 | + params: Promise<{ slug: string[] }>; |
| 105 | + locale?: 'en' | 'cn'; // Optional prop, defaults to 'en' |
| 106 | +}; |
| 107 | + |
| 108 | +export const dynamic = 'force-static'; |
| 109 | + |
| 110 | +// Update getDocFromParams |
| 111 | +async function getDocFromParams({ params, locale = 'en' }: DocPageProps) { |
| 112 | + const slugParam = (await params).slug; |
| 113 | + // Use locale prop instead of searchParams |
| 114 | + if (locale === 'cn') { |
| 115 | + // Chinese logic... |
| 116 | + } |
| 117 | + // ... |
| 118 | +} |
| 119 | +``` |
| 120 | + |
| 121 | +### Step 2: Create CN Docs Route |
| 122 | + |
| 123 | +```tsx |
| 124 | +// apps/www/src/app/cn/docs/[[...slug]]/page.tsx |
| 125 | +import { DocContent } from '@/app/(app)/docs/[[...slug]]/doc-content'; |
| 126 | +import { allDocs } from 'contentlayer/generated'; |
| 127 | +// ... other imports from main page |
| 128 | + |
| 129 | +export const dynamic = 'force-static'; |
| 130 | + |
| 131 | +// IMPORTANT: Generate params for CN docs specifically |
| 132 | +export function generateStaticParams() { |
| 133 | + return allDocs |
| 134 | + .filter((doc) => doc._raw.sourceFileName?.endsWith('.cn.mdx')) |
| 135 | + .map((doc) => ({ |
| 136 | + slug: doc.slugAsParams.replace(/\.cn$/, '').split('/').slice(1), |
| 137 | + })); |
| 138 | +} |
| 139 | + |
| 140 | +export default async function CNDocPage({ params }: { params: Promise<{ slug: string[] }> }) { |
| 141 | + // Render with locale='cn' |
| 142 | + // ... (copy rendering logic with locale hardcoded to 'cn') |
| 143 | +} |
| 144 | +``` |
| 145 | + |
| 146 | +### Step 3: Fix Client-Side Locale Detection |
| 147 | + |
| 148 | +```tsx |
| 149 | +// apps/www/src/hooks/useLocale.ts |
| 150 | + |
| 151 | +// BEFORE - forces client-side hydration issues |
| 152 | +import { useSearchParams } from 'next/navigation'; |
| 153 | + |
| 154 | +export const useLocale = () => { |
| 155 | + const searchParams = useSearchParams(); |
| 156 | + const locale = searchParams?.get('locale') || 'en'; |
| 157 | + return locale; |
| 158 | +}; |
| 159 | + |
| 160 | +// AFTER - derive from pathname |
| 161 | +import { usePathname } from 'next/navigation'; |
| 162 | + |
| 163 | +export const useLocale = () => { |
| 164 | + const pathname = usePathname(); |
| 165 | + return pathname?.startsWith('/cn') ? 'cn' : 'en'; |
| 166 | +}; |
| 167 | +``` |
| 168 | + |
| 169 | +### Step 4: Fix Locale Link Helper |
| 170 | + |
| 171 | +```tsx |
| 172 | +// apps/www/src/lib/withLocale.ts |
| 173 | + |
| 174 | +// BEFORE - adds redundant query param |
| 175 | +export const hrefWithLocale = (href: string, locale: string) => { |
| 176 | + if (locale === 'cn') { |
| 177 | + return `/cn${href}?locale=${locale}`; // Redundant! |
| 178 | + } |
| 179 | + return href; |
| 180 | +}; |
| 181 | + |
| 182 | +// AFTER - path only |
| 183 | +export const hrefWithLocale = (href: string, locale: string) => { |
| 184 | + if (locale === 'cn') { |
| 185 | + return `/cn${href}`; |
| 186 | + } |
| 187 | + return href; |
| 188 | +}; |
| 189 | +``` |
| 190 | + |
| 191 | +### Step 5: Update Next.js Config |
| 192 | + |
| 193 | +```ts |
| 194 | +// apps/www/next.config.ts |
| 195 | + |
| 196 | +// REMOVE these rewrites: |
| 197 | +rewrites: async () => { |
| 198 | + return [ |
| 199 | + { source: '/cn', destination: '/?locale=cn' }, // DELETE |
| 200 | + { source: '/cn/:path*', destination: '/:path*?locale=cn' }, // DELETE |
| 201 | + ]; |
| 202 | +}, |
| 203 | + |
| 204 | +// ADD these redirects for old URLs: |
| 205 | +redirects: async () => { |
| 206 | + return [ |
| 207 | + // ...existing redirects... |
| 208 | + |
| 209 | + // Redirect old ?locale=cn URLs to /cn/* paths |
| 210 | + { |
| 211 | + source: '/', |
| 212 | + has: [{ type: 'query', key: 'locale', value: 'cn' }], |
| 213 | + destination: '/cn', |
| 214 | + permanent: true, |
| 215 | + }, |
| 216 | + { |
| 217 | + source: '/docs/:path*', |
| 218 | + has: [{ type: 'query', key: 'locale', value: 'cn' }], |
| 219 | + destination: '/cn/docs/:path*', |
| 220 | + permanent: true, |
| 221 | + }, |
| 222 | + ]; |
| 223 | +}, |
| 224 | +``` |
| 225 | + |
| 226 | +### Step 6: Fix Home Page (Also Uses searchParams!) |
| 227 | + |
| 228 | +```tsx |
| 229 | +// apps/www/src/app/(app)/page.tsx |
| 230 | + |
| 231 | +// BEFORE |
| 232 | +export default async function IndexPage({ |
| 233 | + searchParams, |
| 234 | +}: { |
| 235 | + searchParams: SearchParams; |
| 236 | +}) { |
| 237 | + const locale = ((await searchParams).locale || 'en') as keyof typeof i18n; |
| 238 | + // ... |
| 239 | +} |
| 240 | + |
| 241 | +// AFTER - remove searchParams, default to 'en' |
| 242 | +export const dynamic = 'force-static'; |
| 243 | + |
| 244 | +export default async function IndexPage() { |
| 245 | + const locale = 'en'; // English home page |
| 246 | + // ... |
| 247 | +} |
| 248 | + |
| 249 | +// Create separate /cn/page.tsx for Chinese home |
| 250 | +``` |
| 251 | + |
| 252 | +## Acceptance Criteria |
| 253 | + |
| 254 | +- [x] Remove `searchParams` from docs page props |
| 255 | +- [x] Remove `searchParams` from home page props |
| 256 | +- [x] Create `/cn/docs/[[...slug]]/page.tsx` with proper `generateStaticParams` |
| 257 | +- [x] Create `/cn/page.tsx` for Chinese home |
| 258 | +- [x] Update `useLocale.ts` to use `usePathname()` |
| 259 | +- [x] Update `withLocale.ts` to remove query params |
| 260 | +- [x] Update language dropdown to use path-only navigation |
| 261 | +- [x] Replace rewrites with redirects in next.config.ts |
| 262 | +- [ ] Verify `x-vercel-cache: HIT` header after deploy |
| 263 | +- [ ] Verify cache hit rate > 90% in Vercel dashboard |
| 264 | +- [ ] Verify costs return to ~$20/month baseline |
| 265 | + |
| 266 | +## Testing Checklist |
| 267 | + |
| 268 | +- [x] Verify build output shows pages as "Static" not "Dynamic" |
| 269 | +- [ ] Test language switcher navigates to `/cn/*` without query params |
| 270 | +- [ ] Test internal doc links preserve locale context |
| 271 | +- [ ] Verify 301 redirects work for old `?locale=cn` URLs |
| 272 | +- [ ] Check no hydration warnings in browser console |
| 273 | + |
| 274 | +## Alternative Approaches Considered |
| 275 | + |
| 276 | +| Approach | Pros | Cons | |
| 277 | +|----------|------|------| |
| 278 | +| Path segments (chosen) | Full caching, SEO-friendly | Route restructuring needed | |
| 279 | +| Cookies for locale | No URL changes | Still dynamic, no caching | |
| 280 | +| ISR with short TTL | Quick fix | Still hits origin frequently | |
| 281 | +| Remove CN support | Simplest | Loses Chinese users | |
| 282 | + |
| 283 | +## Risk Analysis |
| 284 | + |
| 285 | +- **Low risk:** Route restructuring is well-documented Next.js pattern |
| 286 | +- **Migration:** Old `?locale=cn` URLs need 301 redirects to preserve SEO |
| 287 | +- **Testing:** Verify both locales render correctly before deploy |
| 288 | + |
| 289 | +## References |
| 290 | + |
| 291 | +### Internal Files |
| 292 | +- [apps/www/src/app/(app)/docs/[[...slug]]/page.tsx](apps/www/src/app/(app)/docs/[[...slug]]/page.tsx) - Docs page |
| 293 | +- [apps/www/src/app/(app)/page.tsx](apps/www/src/app/(app)/page.tsx) - Home page |
| 294 | +- [apps/www/src/hooks/useLocale.ts](apps/www/src/hooks/useLocale.ts) - Client locale hook |
| 295 | +- [apps/www/src/lib/withLocale.ts](apps/www/src/lib/withLocale.ts) - Locale link helper |
| 296 | +- [apps/www/next.config.ts](apps/www/next.config.ts) - Next.js config |
| 297 | + |
| 298 | +### External Documentation |
| 299 | +- [Next.js i18n Routing](https://nextjs.org/docs/app/building-your-application/routing/internationalization) |
| 300 | +- [Vercel React Best Practices - Server Caching](/.claude/rules/vercel-react-best-practices/AGENTS.md#33-cross-request-lru-caching) |
| 301 | +- [Next.js Static Generation](https://nextjs.org/docs/app/building-your-application/rendering/server-components#static-rendering-default) |
0 commit comments