ORM and SQL in server components: the data layer owns queries, memoization, and the tree-shaped N+1
RSC query the database directly, so discipline moves to the data layer: queries live in lib/data next to the auth check, React cache() dedupes one render pass, Promise.all and preload kill waterfalls, and the tree-shaped N+1 — 50 cards, 50 queries — gets hoisted or batched.
The ops dashboard ships as the proudest page of the App Router migration: every widget is a self-contained server component that fetches exactly the data it shows. Revenue card, error-budget card, top-customers table, 23 widgets in all — beautifully decoupled, trivially testable, each one an async function that awaits its own query. Then someone opens it from the Sydney office and waits 4.6 seconds for the first byte. The trace explains why nobody saw it locally: the widgets nest inside layout sections, each section awaits before rendering its children, and the 23 queries run as a staircase — about 200 ms a step against the us-east database. Nothing is slow; everything is sequential. The team learns the App Router lesson the hard way: when components can talk to the database, the component tree becomes your query planner — and a tree planner defaults to a waterfall.
Queries live in lib/data, not in component bodies
By the end of this section you’ll understand why the shape of your data layer — not the ORM you chose — is what determines whether that 4.6-second dashboard can even be fixed.
A server component can await db.select() right in its body, and that convenience is exactly the thing to ration. The discipline that survives production is a data-access layer: a lib/data/ module per domain, exporting functions that components call — components never import the database client. Three reasons, in rising order of importance. Testability: a function taking ids and returning rows is unit-testable without rendering anything. Reviewability: every query in the codebase lives in a folder you can audit, instead of being smeared across the tree. And the decisive one: the authorization check lives next to the data access, so there is no path to the rows that bypasses it — the same boundary argument you met in the auth unit, now applied to reads.
// lib/data/orders.ts
import 'server-only'; // build error if a client component imports this
import { cache } from 'react';
import { db } from '~/lib/db';
import { requireUser } from '~/lib/auth';
export const getOrder = cache(async (orderId: string) => {
const user = await requireUser(); // auth lives WITH the data access
const order = await db.query.orders.findFirst({
where: (o, { eq }) => eq(o.id, orderId),
});
if (!order || order.accountId !== user.accountId) return null; // ownership, in one place
return order;
});The 'server-only' import makes the boundary mechanical: if this module ever lands in a client bundle, the build fails instead of your connection string leaking.
Prisma, Drizzle, raw SQL — weighed honestly
In an RSC world the ORM question gains a serverless dimension. Prisma has the most mature generated-types story and the best relational ergonomics, but classically ships a Rust query-engine binary — on the order of 10–15 MB in the deployment package — that must spawn and connect on cold start, historically adding hundreds of milliseconds on a small lambda. Recent majors cut query overhead substantially, and the driver-adapters mode drops the engine binary entirely — real progress, still worth verifying against your own cold-start budget rather than taking on faith. Drizzle defines the schema in TypeScript and infers types at compile time: no codegen step, no binary, well under a megabyte of JS, and an API shaped like SQL, so the query you read is roughly the query that runs. Raw SQL over postgres.js or pg is the lightest and the most expressive — window functions, CTEs, no abstraction ceiling — but the type story is yours to build: interface declarations silently drift from the schema unless you add codegen or parse rows at the boundary. The senior take: the choice matters less than where the queries live — but on serverless, cold weight and connection behavior are first-class criteria, not taste.
Request memoization is one render pass, not a cache
Next.js dedupes identical fetch calls automatically within a single render pass. Database queries get no such favor — they are not fetch — which is why the data layer wraps them in React cache(). Within one request, ten components calling getOrder('o_42') produce one query; they all share the same in-flight promise. Be precise about the boundary, because this is where teams burn themselves: cache() memoizes per render pass. It spans generateMetadata, the layout, the page, and every component in that one request — and then the slate is wiped. The next request runs the query again. Different arguments mean different cache entries, so it dedupes repetition, not variety. It is not the Data Cache, it shares nothing across users, and it makes nothing faster for the second visitor. Cross-request caching is a different mechanism with different invalidation problems — that is the cache-tags lesson, not this one.
A team wraps getProduct in React cache() and expects the second visitor to be served from memory. The function is called in generateMetadata, the layout, and the page. How many queries run for visitor one, and what does visitor two get?
Parallel is your job; the tree defaults to serial
When you see a waterfall in your traces, the fix is almost never “use a faster ORM” — it’s structural: change what the parent awaits before its children render.
An RSC tree serializes wherever a parent awaits before rendering children who also await: the waterfall depth equals the nesting depth, which is exactly the dashboard incident — 23 widgets, ~200 ms each, 4.6 s of staircase. Two structural fixes. First, hoist and parallelize: the parent starts every promise, then awaits them together — Promise.all([getRevenue(), getErrors(), getCustomers()]) — and passes results down as props; total time becomes the slowest query, not the sum. Second, the preload pattern — call early, await late: because cache() shares the in-flight promise, a parent can fire void getOrder(id) without awaiting, render children immediately, and the child that later awaits getOrder(id) joins a query that has been running since the top of the tree.
// lib/data/orders.ts — alongside getOrder
export function preloadOrder(orderId: string) {
void getOrder(orderId); // starts the query; whoever awaits later joins this promise
}The tree-shaped N+1
The classic N+1 came from ORMs lazy-loading relations in a loop. The App Router version is architectural: a list page renders 50 ProductCard components, each card — self-sufficient by design — awaits getPrice(product.id), and the page issues 50 queries per request. Siblings render concurrently, so it is not 50 round trips end-to-end, but it is 50 queries hitting the pool at once, 50 slots contended, and at a p50 of 1.5 ms each, real load multiplied by every page view. Two fixes with an honest tradeoff between them. Hoist the query: the list parent runs one WHERE id IN (...50 ids) query and passes rows down as props — cheapest and most explicit, at the cost of the parent knowing what its children need. Or batch DataLoader-style: a per-request batcher (built on cache() so it is request-scoped) collects getPrice calls issued during the same tick and coalesces them into one IN query — components stay self-sufficient, and you now own a batching layer with microtask-timing subtleties. Either way the number that matters moves from 50 to 1.
▸Why this works
Why did the framework move the database next to the component at all, if it recreates N+1? Because the alternative it replaced was worse: an API layer whose endpoints existed only to ferry data to pages, each adding serialization, a network hop, and a second deployment to keep honest. RSC delete that middle tier — the component renders where the data lives, and a query is a function call. The price is that an architectural constraint disappeared: the API layer used to force batching at the endpoint boundary, badly but unavoidably. Now nothing forces it, so the discipline has to come from the data layer you build instead of the tier you deleted.
A list page renders 50 ProductCards; each awaits priceFor(product.id), which is wrapped in React cache(). What actually hits the database on one request?
- 01What exactly does React cache() memoize, and what does it never do?
- 02Your page renders 50 cards that each query their own price. Name the two structural fixes and the tradeoff between them.
Server components turned a query into a function call, which deleted the API middle tier and with it the only structural force that ever made teams batch. The replacement discipline is the data-access layer: every query lives in lib/data behind an exported function, the authorization check sits in that function so no path to the rows bypasses it, and ‘server-only’ makes the boundary a build error instead of a code-review hope. On the ORM question, judge by serverless physics, not aesthetics: Prisma brings the strongest generated-type ergonomics and historically a 10–15 MB engine binary with real cold-start cost — much improved lately, and removable via driver adapters; Drizzle infers types from a TypeScript schema with no binary and almost no weight; raw SQL is lightest and most powerful with a type story you must build yourself. React cache() gives database queries the per-render dedupe that fetch gets free — one query for ten callers within a single request, spanning generateMetadata through the leaves — and nothing more: it is not a cache across requests, and different arguments are different entries. The tree serializes wherever parents await before children: hoist promises into Promise.all, or preload — call the cache-wrapped function without awaiting at the top, let descendants join the in-flight promise. And watch for the tree-shaped N+1: 50 self-sufficient cards are 50 queries per page view, fixed either by hoisting one IN query at the cost of parent-child coupling, or by a per-request DataLoader-style batcher at the cost of owning the batching layer. The component tree is a rendering plan, not a query plan — the lesson of the 4.6-second dashboard is to never let it become one by accident. Now when you see a slow RSC page, check the trace first for a staircase: if every component starts only after the previous one finishes, the fix is hoisting, not optimization.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.