Any shop can put files in an `app/` directory. Ask instead where ISR writes its cache on a three-pod cluster, what busts a tag when the CMS webhook fails, and why the admin delete action needs its own auth check inside the body. Here's the vetting script.
A distributor called us in March: a price correction had been live for six hours on two of their three servers, and the old number was still showing on the third. Nobody had touched the code. ISR was writing revalidated HTML to each pod's local disk, and the load balancer was rotating between three disks that didn't know the others existed. The agency that built it shipped clean code, a 98 Lighthouse score, and had never run Next.js anywhere but Vercel.
Every shop selling Next.js can put files in an `app/` directory. That gets you a homepage. It doesn't get you through month four. Four defects show up in nearly every codebase we inherit: cache semantics nobody on the team can explain, a `"use client"` boundary that leaked through a barrel file, a deployment target chosen after the architecture froze, and server actions written like private functions instead of the public POST endpoints they compile into. Ask about those four.
Four caches, four failure modes: request memoization inside one render, the data cache around `fetch`, the full route cache for static routes, and the router cache that serves a stale page to the user who just submitted your form. Demo teams say "Next handles caching." People who have run it tell you Next 15 flipped `fetch` and GET route handlers to uncached by default — an upgrade that turned one client's cached marketing pages into a database query per visitor — and that a single `cookies()` call inside a shared header opts the entire route into dynamic rendering.
Then ask how a CMS edit reaches production. A bare `revalidate: 60` means stale content for a minute and a synchronized stampede at your origin. The stronger answer is tags applied at the `fetch` call and `revalidateTag` fired from a signature-verified CMS webhook, so one product edit busts exactly the routes that reference that product. Then ask what happens when the webhook fails, and what happens when it wins the race against the CMS's own CDN and revalidates with the old payload. Without a time-based floor under the tags and an alert on webhook non-200s, you get permanently stale pages and nothing tells you.
The most expensive bug we find is a barrel file. Someone adds `"use client"` to `components/index.ts` so one chart renders, and every component re-exported from that file becomes a client component, including the ones that render static text. On one handover that put 190KB of gzipped charting library on a contact form. The build output doesn't complain; First Load JS just creeps. The fix is boring: mark the smallest leaf, pass server-rendered children in as props instead of importing them across the boundary, and never re-export through it.
Ask how they catch it. The real answer is `@next/bundle-analyzer` wired into CI with a per-route First Load JS budget — we set around 130KB gzipped for marketing routes and a separate, larger number for dashboards, because a charting page has a legitimately different profile — failing the build on regression rather than reviewing it by eye. `import 'server-only'` in data modules turns the worst leaks into compile errors. A shop with no number and no gate will drift, because every individual `"use client"` looks reasonable and only the total hurts.
Next.js on Vercel and Next.js in your own cluster are close to two frameworks operationally. ISR, image optimization and middleware are managed there and yours here. ISR writes revalidated pages to the local filesystem, so three replicas mean three caches that disagree with each other — the price bug above. You need a shared `cacheHandler` backed by Redis or S3 in `next.config`, in place before launch. Sticky sessions don't fix it; they just make the disagreement harder to reproduce.
The image optimizer is the other surprise. Self-hosted, `next/image` transcodes with sharp on CPU you are paying for and caches the output per pod. A crawler walking 10,000 SKUs across four breakpoints and two formats is 80,000 transcodes on your nodes. Put a CDN in front of `/_next/image`, or point `loader` at an external image service and let Next emit URLs. And read the middleware matcher: the default one runs on `/_next/static` and favicons too, so you are paying a Node invocation to serve an icon.
So ask where this runs and whether the vendor has operated Next.js there specifically. A Vercel-only team architects for Vercel, and when compliance moves you into a private VPC in year two, you pay for a migration nobody quoted.
A server action reads like a function call, which is exactly why it gets under-secured. It compiles to a public POST endpoint addressed by a generated ID in the `Next-Action` header, and anyone can invoke it with curl and a session cookie. Hiding the button does nothing. We reviewed a handover where the admin delete action checked `session.role` in the page component and nowhere else — any logged-in user could delete any record with one request. The authorization check belongs in the first lines of the action body, before any mutation.
Then validation. Actions receive `FormData`: untyped, entirely attacker-controlled, including hidden fields your form never rendered. Parse it through a Zod schema at the top, return typed error state through `useActionState` instead of throwing into an error boundary, and remember that actions bypass whatever rate limits you configured for `/api`. Ask to see one real action from a shipped repository. The auth check, the schema parse, and the `revalidateTag` at the end tell you more than an hour of portfolio review.
Marketing site on a headless CMS: four to six weeks, fixed scope, simple caching story. Authenticated product with dashboards, billing and role-based access: budget a discovery week that ends in a rendering strategy document listing every route as static, ISR-with-tags, or dynamic, and why. It takes a day to write, and it settles the arguments that otherwise arrive in month three disguised as performance regressions.
Last, check the exit. Repository access from day one, infrastructure defined in code rather than clicked into a dashboard, a README that boots the project in one command, and a runbook for cache invalidation. A shop that hesitates on any of those is selling you dependence, and no amount of App Router fluency is worth that.
A software studio that ships and maintains its own products — KeepChats, Gwora and MoveProof — and builds the same way for clients. Founded and led by codewithumar.
Talk to the studio →Send the problem. You get one fixed number and a plan back within a business day.
We build software worth keeping — for clients, and for ourselves.
Founded & led by codewithumar