In a Next.js App Router codebase, any server component can query the database directly. As the engineer setting the standard, how do you decide between colocating queries in the components that need them and routing every read through a shared data access layer?
answer
- the choke point disappeared with the endpoint
- locality is worth keeping
- policy needs one home
- serial cost versus parallel load
- count the queries a route issues
basics
~20 sColocate the call, centralise the query. Components should ask for what they need from named functions in a server-only data access module, so authorization, query shape and instrumentation live in one place while each component still fetches independently.
solid answer
~50 sThe framework's affordance — any Server Component can reach the database — is a locality win you should keep, but it removes the choke point the old API layer gave you for authorization, query budgeting and observability. My standard is a hybrid: components never write queries, they call named functions from a `lib/data` module marked with `server-only`, and every one of those functions performs its own authorization check next to the data rather than trusting an upstream guard. That preserves per-component fetching and streaming while giving one place to audit reads, cap query shapes and add tracing. I then govern the failure mode colocation actually causes, which is fan-out: a route quietly issuing thirty queries because thirty components each fetched their own slice. I measure queries per route, treat a rising count as a review signal, and collapse the worst offenders into a single aggregate call passed down as props.
go deeper
Know that a Server Component can query data directly, and that teams usually put those queries in shared functions rather than writing raw queries inside components.
Explain both sides concretely: colocation keeps the query next to the markup and lets sections load independently, while a shared module gives one place for query text, projections and reuse.
Demonstrate the production consequence — authorization has no single choke point any more, and per-component fetching fans out into many queries per route — and describe how you would detect and cap both.
Own the policy and its cost: state the rule you would write, say what evidence would make you tighten or relax it, and be honest that a layer with no second reason to exist is indirection the team pays for daily.
## What the affordance gives and what it removes When every component can read data, three good things follow. Locality: the markup and the query that feeds it live together, so deleting the component deletes the query. Independence: a slow panel does not have to be hoisted into a page-wide loader, so it can be isolated and streamed on its own. No prop drilling: a deeply nested widget fetches its own counts instead of threading them through five layers. One bad thing follows too, and it is structural rather than stylistic. The API layer used to be a choke point, and choke points are where policy lives. Every read passed through a handler, so that handler was the natural home for the authorization check, the query shape, the rate limit and the log line. Remove the choke point without replacing it and those concerns scatter to wherever someone happened to write `db.user.findMany`. ## The standard I actually set **Components call functions; components do not write queries.** A module such as `lib/data/orders.ts` exports `getOrdersForCurrentUser()`; the page calls that. The component keeps its locality — it still asks for exactly what it renders, still fetches independently — but the query text lives in one auditable place. **Mark those modules `server-only`.** A data access module that a client component could import is a data access module that will eventually be imported by one. Making that a build failure is cheap. **Authorize inside the function, not upstream.** The check belongs next to the read, because in the App Router there is no single gate every read passes. A layout does not re-render on every navigation and does not wrap the data of routes it does not own, so treating it as the guard leaves gaps; upstream request-level filtering is a useful first line, not the last. Each exported data function should establish the caller and scope the query itself — the pattern is that a function which cannot say who is asking should not return rows. **Make the return shape explicit.** Server Components can hand data straight to client components, and a raw row carries columns nobody meant to expose. Data functions should return a deliberate projection, not the record the ORM handed back. ## The failure mode to govern: fan-out Waterfalls are the latency pathology; fan-out is the load pathology, and colocation causes it. Thirty components each fetching their own slice is thirty queries per render, none of them individually unreasonable, all of them together a problem for a datastore that used to see three. It is invisible in code review because every diff adds only one query. So I instrument it. Count queries per route and track the number over time; treat a jump as a review trigger the way a bundle-size budget works. When a route is genuinely fan-out heavy, the fix is not to abandon colocation but to collapse the hot cluster: one aggregate call in a parent, results passed down as props to the components that used to fetch individually. That is a deliberate, local trade of locality for load, made where the measurement says it is needed rather than everywhere by policy. ## When I would centralise harder There are cases where I would push further toward a strict layer. When the data is regulated and every read must be logged with a subject and purpose, the audit requirement wants one narrow surface. When the same entity is read by a mobile client and a background job as well as this app, the query logic should live somewhere both can call, and the Next app is one consumer among several. When the team is large enough that ownership boundaries matter more than local convenience, a layer with an owner beats a convention. ## When I would leave it loose A small app with one datastore, one team and no compliance surface pays real cost for ceremony. A wrapper function per query that adds nothing but indirection is worse than the query, because now a reader chases two files to learn one fact. The honest version of the standard is: introduce the layer when there is a second reason for it to exist beyond tidiness — authorization, auditing, a second consumer, or a measured fan-out problem. ## How I would tell the team As a rule with a reason, not a taste: *a Server Component may fetch anything it renders, through a named function in `lib/data` that authorizes the read itself.* One sentence, checkable in review, and it names the two things that must not be negotiable — the check and the single home for query text — while leaving fetching where the framework wants it.
- Why not put the authorization check in a layout that wraps the protected routes?Because a layout is not a gate every read passes. It does not re-render on every navigation within its subtree, and it does not wrap data fetched by routes outside it, so it can be bypassed by any component that fetches on its own. Upstream filtering is a first line; the read itself must still establish who is asking.
- How would you know a route has a fan-out problem before a user does?Instrument query count and total data time per route render, then track both as budgets the way you track bundle size. A route whose query count climbs from four to thirty over a quarter never fails a single review, because each diff added one. The number is what makes the drift visible.
- What is the cost of the data access layer you are recommending?Indirection. A reader now opens two files to learn what a page reads, and a wrapper that only forwards a query is pure ceremony. I would introduce the layer where it earns its keep — authorization, auditing, a second consumer, or measured fan-out — rather than as a blanket rule from day one.
saying these in an interview costs you the question
- Reintroduces a single page-level loader and prop-drills everything
- Trusts a layout or upstream guard as the only authorization check
- Treats colocation as free and never measures queries per route
- Returns raw ORM records straight into client component props
- Adds a wrapper per query that provides no policy or projection