The Senior Frontend Engineering Blueprint, Part 3: Parallel Routes, TanStack Query and a Tailwind v4 Design System
Part 3 of an eight-part build: the file-system pattern behind Instagram-style shareable modals, prefetching TanStack Query on the server and hydrating it on the client without a duplicate fetch, a Tailwind v4 @theme design system with a CVA button primitive, and why Core Web Vitals numbers stay aspirational until you actually reserve the layout space.
A bookmarkable photo viewer has an awkward requirement built into it: click a thumbnail in a feed and it should open as an overlay, in place, with the feed still visible behind it — but paste that same URL into a new tab, or hit refresh, and it needs to render as a full, standalone page. Most teams solve this by picking one behaviour and living with the other being wrong. The overlay-only version breaks on refresh. The full-page-only version loses the feed context on every click. Next.js has a file-system convention that gives you both at once, and this part starts there before moving on to the two other pieces of a production frontend that Part 3 of this eight-part series covers: hydrating a TanStack Query cache across the server/client boundary without double-fetching, and a Tailwind v4 design system built the way a CVA primitive actually gets used in a real component library.
Parallel and intercepting routes solve the shareable-modal problem
A parallel route lets a layout render more than one page at the same URL, in named slots. A intercepting route lets navigating to one URL render a different segment's UI, but only when that navigation originates from within the app — a direct load or a refresh still gets the real, unintercepted route. Combined, they produce the Instagram pattern: click a post from the feed and its @modal slot intercepts the navigation and renders an overlay; load the same URL cold and you get the dedicated page.
app/feed/
├── layout.tsx <- injects two slots: children and @modal
├── page.tsx <- the feed listing itself
├── @modal/
│ ├── default.tsx <- rendered when nothing matches the slot
│ └── (.)posts/[id]/
│ └── page.tsx <- intercepts navigation to /posts/[id] from /feed
└── posts/[id]/
└── page.tsx <- the real route: direct load, refresh, share linkThe (.) prefix is a matching convention, not a real path segment — it means "intercept a route at this same level." (..) walks up one segment, (..)(..) two, and (...) matches from the root regardless of nesting depth. Pick the one that matches where the source page sits relative to the target page, not where the target page sits in the URL.
Every named slot needs a default.tsx, and it is easy to skip because nothing breaks locally when you do — until a user navigates straight to a sibling route inside the same layout and Next.js has no fallback to render for the slot that didn't match, which surfaces as a 404 on a page that should have loaded fine.
import type { ReactNode } from "react";
export default function FeedLayout({
children,
modal,
}: {
children: ReactNode;
modal: ReactNode;
}) {
return (
<div className="relative min-h-screen">
{children}
{modal}
</div>
);
}modal here is not a prop name Next.js invents — it is derived from the folder name @modal, and it has to appear as a prop on the layout beside children or the slot's content is never rendered anywhere.
export default function DefaultModalSlot() {
return null;
}import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}That is the same singleton from Part 1, reused here because the intercepted route needs a real query, not a prop — a modal reached by direct navigation has no feed component upstream to hand it data.
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
interface InterceptedPostModalProps {
params: Promise<{ id: string }>;
}
export default async function InterceptedPostModal({
params,
}: InterceptedPostModalProps) {
const { id } = await params;
const post = await prisma.post.findUnique({ where: { id } });
if (!post) notFound();
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
<div className="w-full max-w-lg rounded-xl border border-neutral-700 bg-neutral-900 p-6 text-white shadow-2xl">
<span className="text-xs uppercase tracking-wider text-emerald-400">
Intercepted route
</span>
<h2 className="mt-2 text-2xl font-bold">{post.title}</h2>
<p className="mt-4 text-neutral-300">{post.content}</p>
<p className="mt-4 text-xs text-neutral-500">
Refreshing the browser here loads the real /posts/[id] page instead of this
overlay.
</p>
</div>
</div>
);
}app/feed/posts/[id]/page.tsx — the fallback target sitting outside @modal — is a completely ordinary page with the same query and no overlay chrome. It is what direct navigation and refresh actually render; the interception only ever fires for client-side navigations that originate from inside /feed. Skip that fallback route and the shareable URL you were building this for stops being shareable — a link pasted into Slack renders a 404 the moment @modal's intercepted segment isn't in the navigation history to catch it.
TanStack Query v5: prefetch on the server, hydrate on the client
Server Components already fetch data without a client library. TanStack Query earns its place alongside them for the data that a client component needs to own after the initial render — refetch on window focus, invalidate on mutation, poll on an interval — without every one of those behaviours becoming bespoke useEffect plumbing. The pattern that makes the two coexist is: prefetch on the server during the RSC render pass, dehydrate the resulting cache into a serialisable snapshot, and hydrate that snapshot into the client's QueryClient before the first paint, so the client component's useQuery call resolves from cache instead of firing a second network request.
The QueryClient itself needs a split lifecycle: one instance per request on the server, because a module-scoped singleton would leak state between unrelated requests, and a genuine singleton in the browser, because creating a new client on every render would blow away the very cache you're trying to persist across navigations. isServer, exported from @tanstack/react-query, is what decides between the two branches.
import { QueryClient, isServer } from "@tanstack/react-query";
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
gcTime: 5 * 60 * 1000,
},
},
});
}
let browserQueryClient: QueryClient | undefined;
export function getQueryClient() {
if (isServer) {
return makeQueryClient();
}
if (!browserQueryClient) {
browserQueryClient = makeQueryClient();
}
return browserQueryClient;
}import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "@/lib/query-client";
import { prisma } from "@/lib/prisma";
import { PostsList } from "@/components/explore/PostsList";
export default async function ExplorePage() {
const queryClient = getQueryClient();
await queryClient.prefetchQuery({
queryKey: ["explore-posts"],
queryFn: () =>
prisma.post.findMany({
take: 20,
orderBy: { createdAt: "desc" },
}),
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<main className="p-8">
<h1 className="text-3xl font-bold text-white">Explore posts</h1>
<PostsList />
</main>
</HydrationBoundary>
);
}"use client";
import { useQuery } from "@tanstack/react-query";
interface ExplorePost {
id: string;
title: string;
}
export function PostsList() {
const { data, isLoading, error } = useQuery<ExplorePost[]>({
queryKey: ["explore-posts"],
queryFn: async () => {
const res = await fetch("/api/posts");
return res.json() as Promise<ExplorePost[]>;
},
});
if (isLoading) return <div className="text-white">Loading…</div>;
if (error) return <div className="text-red-400">Could not load posts.</div>;
return (
<ul className="mt-4 space-y-2">
{data?.map((post) => (
<li key={post.id} className="rounded border border-neutral-800 p-4 text-white">
{post.title}
</li>
))}
</ul>
);
}Two things here are easy to get quietly wrong. First, the query key — ["explore-posts"] — has to be byte-for-byte identical between the server prefetch and the client useQuery, because that key is the only thing connecting the dehydrated cache entry to the component reading it; a mismatched key means HydrationBoundary hydrated data nobody asks for, and PostsList mounts into a loading state on data that already sits in memory two lines away. Second, the server's queryFn and the client's queryFn are two different functions here — one calls Prisma directly, the other calls a /api/posts Route Handler — and TanStack Query has no way to verify they return the same shape. That divergence only matters the moment the client cache goes stale and actually refetches (a window refocus, a manual invalidateQueries), at which point a schema drift between the two shows up as a silent shape mismatch in production, not a compile error. The fix, when the two really can share logic, is to put the query in one data-access function and call it from both the RSC prefetch and the Route Handler, rather than trusting two authors — or two years apart in the same file — to keep them in sync by hand.
A Tailwind v4 design system: @theme and a CVA button primitive
Tailwind v4 replaced tailwind.config.js with a CSS-first configuration: an @import "tailwindcss"; statement and an @theme block that defines design tokens as native CSS custom properties, which Tailwind then turns into utility classes automatically. There is no build-time JavaScript config to keep in sync with the CSS anymore — the token is the CSS variable, referenced directly by anything that needs the raw value (a chart library, an inline style, another stylesheet) as well as by the generated utilities.
@import "tailwindcss";
@theme {
--font-sans: var(--font-geist-sans), system-ui, sans-serif;
--font-mono: var(--font-geist-mono), monospace;
--color-brand-50: oklch(0.97 0.02 240);
--color-brand-500: oklch(0.55 0.22 250);
--color-brand-900: oklch(0.25 0.15 255);
}
@layer base {
body {
background-color: #0a0a0a;
color: #ededed;
}
}Declaring --color-brand-500 in @theme is what makes bg-brand-500, text-brand-500, border-brand-500 and every colour-aware variant of them exist as utilities at all — the naming isn't a convention Tailwind guesses at, it's a direct token-to-class derivation, so a typo in the custom property (--colour-brand-500, --color-Brand-500) silently produces no utility rather than an error, and the class just doesn't apply.
A button primitive is the smallest component that forces you to confront the actual problem a design system exists to solve: composing a fixed set of utility classes with a caller-supplied className without one silently overriding the other in the wrong direction. class-variance-authority (CVA) defines the variant matrix; clsx does the conditional joining; tailwind-merge is the part people skip and then hit the bug it exists to prevent.
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-brand-500 text-white hover:bg-brand-500/90",
destructive: "bg-red-600 text-white hover:bg-red-700",
outline: "border border-neutral-700 bg-transparent text-neutral-200 hover:bg-neutral-800",
ghost: "text-neutral-200 hover:bg-neutral-800",
},
size: {
sm: "h-8 px-3 text-xs",
md: "h-10 px-4 py-2",
lg: "h-12 px-8 text-base",
},
},
defaultVariants: {
variant: "default",
size: "md",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
ref={ref}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
},
);
Button.displayName = "Button";The uncomfortable part: "px-2 px-4" on a single element is not a Tailwind error, and the CSS cascade does not resolve it by "the more specific one wins" — both are equally specific utility classes, so whichever rule was declared later in the generated stylesheet wins, which in practice means whichever class Tailwind happened to emit last, which has nothing to do with which one the author intended to win. Write <Button className="px-2" /> against a primitive whose own variants already include px-4, and depending on build order you can get either value, silently, with no warning from anything in the toolchain — because as far as the CSS cascade and the JSX are concerned, there are simply two valid rules and one specificity tie-break, and neither has any concept of "the caller's override should always take priority." tailwind-merge exists specifically to fix that: it parses each class down to its Tailwind category (here, horizontal padding) and keeps only the last one per category, so a caller-supplied className reliably beats the primitive's own defaults regardless of source order. Skip it on a shared component library and you get a bug report that reads "the padding prop doesn't work," which is really "the padding prop works about half the time, depending on the day's bundle output."
Core Web Vitals: what actually moves LCP, INP and CLS
| Metric | Target | Usual root cause | Fix that actually works |
|---|---|---|---|
| LCP (Largest Contentful Paint) | ≤ 2.5s | Unoptimised hero media, or a slow time-to-first-byte before the largest element can even start loading | next/image with priority on the one hero image, next/font to avoid a late font swap that repaints the largest text block |
| INP (Interaction to Next Paint) | ≤ 200ms | A long synchronous task blocking the main thread during a click, keypress or tap | useTransition for non-urgent state updates, moving genuinely heavy work off the main thread |
| CLS (Cumulative Layout Shift) | ≤ 0.1 | Images or injected content with no reserved space, so the page jumps once they load | Explicit width/height or aspect-ratio on every image slot, before it has any pixels to show |
That table is aspirational, not descriptive, until the layout actually reserves the space these fixes assume. next/image computes an intrinsic aspect ratio and reserves the box before the image arrives — but only when it has enough information to do so, which for a fill image means the parent needs an explicit size, not the image itself:
import Image from "next/image";
export function OptimizedHeroImage() {
return (
<div className="relative aspect-video w-full overflow-hidden rounded-xl border border-neutral-800">
<Image
src="https://images.unsplash.com/photo-1579783902614-a3fb3927b675"
alt="Abstract hero artwork"
fill
priority
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover"
/>
</div>
);
}aspect-video on the wrapper is not decorative here — it is the CLS fix. Remove it and the browser has nowhere to put the image's box until the network request resolves, and the page reflows around it the moment it does, regardless of what next/image does internally. priority is the LCP half: it tells Next.js to add a <link rel="preload"> for this specific image so the browser requests it immediately rather than discovering it after parsing the rest of the document — and it should appear on exactly the image that is actually the page's largest contentful element, because marking every above-the-fold image priority just means the browser now contends for bandwidth across several "urgent" requests instead of one, which is a slower LCP than picking correctly.
Fonts follow the same logic for the text half of LCP: a font that arrives late either blocks text from painting at all (font-display: block, invisible text — a genuine LCP hit) or paints in a fallback and reflows on swap (font-display: swap, a CLS hit if the metrics differ enough). next/font self-hosts the font files at build time — no request to Google's CDN at runtime — and sets display: "swap" explicitly, so the trade-off is made once, deliberately, instead of inherited from whatever the browser's default happens to be:
import type { ReactNode } from "react";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
display: "swap",
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
display: "swap",
});
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}>
<body className="antialiased">{children}</body>
</html>
);
}Notice the --font-geist-sans variable name here is exactly what the @theme block above reads with var(--font-geist-sans) for --font-sans. That chain — font loader variable, to CSS custom property, to Tailwind token — is the kind of thing that silently degrades to a system-font fallback if any one name drifts from the other two, with no error anywhere, because a missing CSS custom property just evaluates to nothing rather than failing a build.
The verification sequence
Before calling any of this done, run all four in order — each one catches a different class of mistake the others miss:
pnpm tsc --noEmit
npx prisma validate
pnpm run build
pnpm run starttsc --noEmit catches the type errors. prisma validate catches a schema that drifted from the database it describes. pnpm run build is the one that actually exercises generateStaticParams, route interception, and the static/dynamic split — a page can type-check cleanly and still fail to build if, say, an intercepted route and its fallback disagree on params shape. pnpm run start runs the production server against the production build, which is the only mode that reflects real caching behaviour; next dev intentionally disables several of the optimisations this article relies on.
Senior interview reference
| Topic | The answer that holds up under a follow-up question |
|---|---|
| State management boundaries | Keep server cache, URL state and ephemeral UI state in three separate systems. Server cache lives in RSC data fetches and TanStack Query, not useState. Bookmarkable filters and pagination live in the URL (nuqs or useSearchParams), not component state. A lightweight store (Zustand) is for state that is genuinely local to the client and has no server or URL representation at all. Syncing server data into useState via a useEffect is the anti-pattern interviewers are listening for you to name unprompted. |
| Server Actions vs. Route Handlers | Server Actions for UI-driven mutations — they collapse the client fetch, the endpoint and the revalidation into one typed function call. Route Handlers for anything that isn't triggered by your own UI: webhooks, a public REST surface for other clients, binary streaming, or an endpoint a TanStack Query client fetches directly. |
| Database performance | Cursor-based (keyset) pagination over offset pagination once a table is large — WHERE id < :cursor ORDER BY id DESC LIMIT :n uses an index seek, SKIP n LIMIT m degrades linearly because the database still has to walk and discard the first n rows. Reuse one Prisma client via the globalThis singleton in development, or hot reload exhausts the connection pool in minutes. |
| Hydration mismatches | The server-rendered HTML and the client's first render disagree — usually window/document access during render, a Date.now() or Math.random() call that produces different output each time, or a browser extension mutating the DOM before React hydrates. Guard browser-only code behind useEffect, or reach for a dynamic import with SSR disabled when a component genuinely cannot render server-side at all. |
The compressed version
- Parallel routes (
@modal) plus intercepting routes ((.)segment) give a modal a shareable, refreshable URL without giving up the in-app overlay — but only if the plain fallback route outside the slot actually exists. - Prefetch a TanStack Query cache on the server,
dehydrateit into aHydrationBoundary, and match the query key exactly on the client — and keep the server and clientqueryFnlogic in sync, because nothing else will. - Tailwind v4's
@themeblock is where a custom property becomes a utility class; a typo there fails silently, not loudly. tailwind-mergeexists because two conflicting utility classes resolve by source order in the generated stylesheet, not by caller intent — skip it on a shared component and an override "works" until a rebuild reorders the CSS.- LCP and CLS are architecture decisions (
priority, reserved aspect ratios, self-hosted fonts), not values you tune after the fact.
Part 4 moves away from the App Router entirely for one article: building a virtualised list that renders 50,000 rows without 50,000 DOM nodes, and keeping it accessible while doing so.