# Scanner patterns AST/grep-style scanners run in parallel with metric-driven investigation. They find known anti-patterns. Findings on cold-path or unmappable files are dropped unless the scanner declares `trafficIndependent: true`. Total scanners: 15. ## Patterns ### `cache-components-suspense-dedupe` — 'use cache' with multiple Suspense boundaries on the same data - **Severity**: medium - **Billing dimension**: function-duration - **Traffic-independent**: no (cold-path findings get dropped) **Description.** Default `'use cache'` does not dedupe identical calls across separate `` boundaries on the same render. Each boundary re-invokes the cached function, multiplying function-duration cost and inflating ISR write churn when the output is large. **Fix.** Hoist the promise to the page level (`const dataPromise = fetchData()` at the top, passed down to each Suspense child) OR move the shared fetch into a `'use cache: remote'` data-access layer so cross-request and cross-boundary dedupe applies. **Citations:** - `https://nextjs.org/docs/app/api-reference/directives/use-cache` - `https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents` - `https://nextjs.org/docs/app/guides/migrating-to-cache-components` --- ### `edge-heavy-import` — Heavy / node-only import inside edge-runtime file - **Severity**: high - **Billing dimension**: function-duration - **Traffic-independent**: yes (cold-path findings survive the doctrine drop) **Description.** Edge runtime is a constrained sandbox with no node: builtins and a much smaller cold-start budget than Node functions. Heavy SDKs (sharp, @aws-sdk/*, @prisma/client, pg, puppeteer) either fail at deploy or inflate cold-start latency. Move the import to a Node runtime function, or replace with an edge-compatible alternative (e.g., neon-driver instead of pg). **Fix.** Either (a) drop the `export const runtime = 'edge'` so the route runs on Node (default in 2026), or (b) replace the heavy import with an edge-compatible alternative. For DB: use @neondatabase/serverless or @planetscale/database instead of pg/mysql2. For image: do the work in a Node route handler. For auth signing: use jose (Web Crypto) instead of jsonwebtoken. **Citations:** - `https://vercel.com/docs/functions/runtimes/edge-runtime` - `https://vercel.com/docs/fluid-compute` --- ### `force-dynamic` — export const dynamic = 'force-dynamic' - **Severity**: medium - **Billing dimension**: function-duration - **Traffic-independent**: no (cold-path findings get dropped) **Description.** force-dynamic disables static + ISR rendering. The route runs the function on every request. Sometimes necessary (cookies, headers, real-time data), often a habit that costs function-duration and edge-requests at scale. **Fix.** Audit the route. If dynamic behavior comes from cookies()/headers()/searchParams, force-dynamic may be redundant — Next infers dynamic automatically. Consider revalidate / 'use cache' / generateStaticParams if any portion can be pre-rendered. **Citations:** - `https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config` --- ### `headers-in-page` — Dynamic API call forcing dynamic rendering - **Severity**: medium - **Billing dimension**: function-duration - **Traffic-independent**: no (cold-path findings get dropped) **Description.** headers(), cookies(), and draftMode() are dynamic APIs. Reading them in a page/layout makes the entire segment dynamic — no ISR, no static generation, and a function invocation on every request. **Fix.** Move the dynamic API call into a child Server Component that lives inside a Suspense boundary. The parent can stay static; only the leaf re-renders dynamically. **Citations:** - `https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config` - `https://nextjs.org/docs/app/building-your-application/caching` --- ### `large-static-asset` — Large file in public/ - **Severity**: medium - **Billing dimension**: bandwidth - **Traffic-independent**: yes (cold-path findings survive the doctrine drop) **Description.** Static assets in `public/` over 500 KB ship as-is from the CDN. Whether the cost is meaningful depends on traffic, but the candidate is binary — the file is either needed at that size or it can be optimized (compressed image, video transcode, or moved off the critical path). **Fix.** Verify the asset is reachable on the customer-facing hot path. Then choose: (a) compress (convert PNG → AVIF/WebP; transcode MP4 to lower bitrate); (b) host externally (Vercel Blob, S3, or a media CDN with per-asset signed URLs); (c) lazy-load (defer to client-side fetch instead of bundling into initial HTML). **Citations:** - `https://vercel.com/docs/manage-cdn-usage` - `https://vercel.com/docs/image-optimization` --- ### `max-age-without-s-maxage` — Cache-Control: max-age without s-maxage - **Severity**: medium - **Billing dimension**: edge-requests - **Traffic-independent**: no (cold-path findings get dropped) **Description.** max-age caches in the browser; s-maxage caches at the CDN. Without s-maxage, every uncached visitor request invokes the function. Adding s-maxage often cuts function invocations by 80%+ on read-heavy routes. **Fix.** Add s-maxage to the Cache-Control header. Example: Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=86400. Pair with explicit cache-bust strategy if content can change. **Citations:** - `https://vercel.com/docs/caching/cdn-cache` - `https://vercel.com/docs/caching/cache-control-headers` --- ### `middleware-broad-matcher` — Middleware matcher missing or too broad - **Severity**: high - **Billing dimension**: edge-requests - **Traffic-independent**: yes (cold-path findings survive the doctrine drop) **Description.** middleware.ts without a config.matcher (or matcher: ["/(.*)"]) runs on every request including _next/static, _next/image, favicon.ico, and image asset fetches. Edge-request cost scales accordingly. **Fix.** Scope the matcher to actual application paths. Example: matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)"] **Citations:** - `https://nextjs.org/docs/app/building-your-application/routing/middleware` --- ### `missing-cache-headers` — Cacheable route or fetch with no caching (Cache-Control absent or no-store) - **Severity**: medium - **Billing dimension**: edge-requests - **Traffic-independent**: no (cold-path findings get dropped) **Description.** Two antipatterns: (a) GET handlers without explicit Cache-Control headers serve uncached; (b) fetch() calls with cache:"no-store" or next:{revalidate:0} opt out of caching even on cacheable upstream data. For non-auth routes / fetches, both are leaving cache hits on the floor. **Fix.** For GET handlers: return a Response with Cache-Control: public, s-maxage=, stale-while-revalidate=. For fetch(): drop cache:"no-store" (use { next: { revalidate: } } in Next.js) so the response is cached by the framework + CDN. **Citations:** - `https://vercel.com/docs/caching/cdn-cache` - `https://vercel.com/docs/caching/cache-control-headers` - `https://nextjs.org/docs/app/building-your-application/caching` --- ### `prisma-include-tree-bloat` — Deep Prisma include tree (3+ levels) - **Severity**: high - **Billing dimension**: function-duration - **Traffic-independent**: no (cold-path findings get dropped) **Description.** Nested .include({ x: { include: { y: { include: { z: ... } } } } }) makes Prisma issue a single huge join that scales O(N*M*K). Function duration explodes, memory spikes, often causes timeouts. **Fix.** Replace with explicit .findMany() calls or scoped .include() of only what the consumer reads. Consider Prisma.select() to project specific fields. For lists, batch with DataLoader patterns. **Citations:** - `vercel-react-best-practices:server-parallel-fetching` --- ### `region-pin-in-config` — Function region pinned in config - **Severity**: low - **Billing dimension**: function-duration - **Traffic-independent**: yes (cold-path findings survive the doctrine drop) **Description.** vercel.json `regions` or per-route `preferredRegion` is set. If the pinned region is far from the dominant user geo (or far from a data source) p95 TTFB suffers. This scanner provides the configured-region signal so the region-misconfig gate can recommend an audit. **Fix.** Audit the pinned region against traffic geography (Speed Insights or Web Analytics by country) and data-source location. Consider multi-region if data lives in a fixed location and users are global; consider relocating if users are concentrated in one geography. **Citations:** - `https://vercel.com/docs/functions/configuring-functions/region` - `https://vercel.com/docs/functions/configuring-functions/region` --- ### `source-maps-production` — Source maps enabled in production - **Severity**: low - **Billing dimension**: edge-requests - **Traffic-independent**: yes (cold-path findings survive the doctrine drop) **Description.** productionBrowserSourceMaps: true ships .map files in the production bundle, increasing transfer size 30-100% per visitor. Useful for error reporting via Sentry; not useful for users. **Fix.** Keep source maps generation but exclude them from the public bundle. Upload to your error tracker via build-time CI step; do not serve them with the deployment. **Citations:** - `https://nextjs.org/docs/messages/improper-devtool` --- ### `sveltekit-prerender-missing` — SvelteKit page without explicit prerender / ISR config - **Severity**: low - **Billing dimension**: function-duration - **Traffic-independent**: no (cold-path findings get dropped) **Description.** SvelteKit page or +page.server.ts is missing an explicit `prerender`, `ssr`, or adapter `config.isr` declaration. Default is per-request function execution — investigate whether the route could be prerendered or ISR-cached. **Fix.** If the page is static (no per-user / per-request data), add `export const prerender = true` in +page.ts or +page.server.ts. If the data refreshes on a schedule, prefer adapter-vercel's ISR option via `export const config = { isr: { expiration: 60 } }`. **Citations:** - `https://kit.svelte.dev/docs/page-options` - `https://kit.svelte.dev/docs/adapter-vercel` - `https://vercel.com/docs/incremental-static-regeneration` --- ### `turbo-force-bypass` — Turborepo cache bypass on a monorepo - **Severity**: high - **Billing dimension**: build - **Traffic-independent**: yes (cold-path findings survive the doctrine drop) **Description.** Turborepo's per-task cache can be bypassed by an explicit force flag, a `cache: false` config, or missing build-skip configuration. Every commit can rebuild unchanged work; Build Minutes climb with project count. **Fix.** Remove `TURBO_FORCE=true` from build env/scripts unless intentional. Set `tasks.build.cache: true` in `turbo.json` (or remove the override), and include generated outputs in Turbo's cache contract. Prefer Vercel's skip-unaffected monorepo behavior when available; use `ignoreCommand` only when that setting cannot cover the project. **Citations:** - `https://vercel.com/docs/monorepos` - `https://vercel.com/docs/builds` - `https://turborepo.dev/docs/crafting-your-repository/caching` --- ### `unoptimized-image` — Image optimization gap (raw , global flag, missing sizes, or SVG mis-routed) - **Severity**: high - **Billing dimension**: image-optimization - **Traffic-independent**: no (cold-path findings get dropped) **Description.** Four shapes of image-cost waste: raw tags bypass the framework Image component; `images.unoptimized: true` disables Vercel image optimization globally; without `sizes` forces serving the largest source variant; without `unoptimized` routes vector data through the raster pipeline. **Fix.** For raw : switch to next/image, enhanced-img (SvelteKit), (Astro), or NuxtImg. For global unoptimized:true: remove the flag unless the project is hosted outside Vercel. For fill without sizes: add `sizes="(max-width: 768px) 100vw, 50vw"` or whatever matches your layout. For SVG: add `unoptimized` so the raw SVG ships instead of rastering it. **Citations:** - `https://nextjs.org/docs/app/api-reference/components/image` - `https://vercel.com/docs/image-optimization` --- ### `use-cache-date-stamp` — new Date() / Date.now() / Math.random() inside a 'use cache' file - **Severity**: high - **Billing dimension**: isr - **Traffic-independent**: no (cold-path findings get dropped) **Description.** `'use cache'` memoizes by argument identity AND prerender output. A timestamp baked into the cached output (`new Date().getFullYear()` in a footer, `Date.now()` in a payload field) forces a fresh ISR write on every regeneration even when the underlying data is unchanged. Random values have the same failure mode. **Fix.** Replace module-scope `new Date()` with a build-time constant (`const buildYear = new Date().getFullYear()`) or move per-request timestamps into a client component inside `useEffect`. Do not pass dates as arguments to `'use cache'` functions — they invalidate the cache every call. **Citations:** - `https://nextjs.org/docs/app/api-reference/directives/use-cache` - `https://nextjs.org/docs/app/api-reference/functions/cacheLife` ---