Type-safe frontends with Next.js and Tailwind
Patterns that hold up across a dozen Next.js apps — typed data flow, component boundaries, and where to stop abstracting.
The type system is the design system
On most React codebases I've worked in, the thing that decays fastest isn't the CSS — it's the shape of the data flowing through the components. A prop that was string becomes string | null becomes string | null | undefined becomes an untyped escape hatch six months later, and nobody notices until a runtime error does.
The fix isn't more discipline. It's fewer places where the shape can drift.
Server Components narrow the problem
Fetching data in a Server Component and passing it down as already-typed, already-validated props removes an entire category of client-side loading states and the optional chaining that comes with them. The component tree below a data boundary can assume its input is correct, because it is.
async function ProjectPage({ params }: { params: { id: string } }) {
const project = await getProject(params.id); // throws if missing
return <ProjectSummary project={project} />; // project is never null here
}The getProject call either resolves to a fully-typed Project or throws, which notFound()/an error boundary catches upstream. ProjectSummary never has to check for null, and the type checker enforces it.
Zod at the boundary, inferred types everywhere else
Every place external data enters the app — a form submission, a webhook, a fetch response — gets validated once with a Zod schema, and every type used downstream is z.infer<typeof schema> rather than a hand-written interface that can silently drift from the runtime check.
This isn't just for API routes. Server Actions get the same treatment — validate the FormData at the top, return a typed result, and the client component consuming it never touches raw form values.
Tailwind as a constraint, not a shortcut
The critique of utility-first CSS — that it moves clutter from the stylesheet into the markup — is fair for a codebase with no conventions. It stops being true the moment you extract repeated utility strings into a small number of named variants instead of copy-pasting class strings across files.
| Pattern | Solo project | Team of 4+ |
|---|---|---|
| Inline utilities | Fine | Drifts fast |
class-variance-authority variants | Overkill | Worth it |
| Component-level composition | Good default | Good default |
The rule that's held up: a component either owns its own layout classes, or it accepts a className prop for the outside margin/positioning only — never both, and never a component that lets a caller override its internal spacing. That boundary is what keeps a Tailwind codebase legible past a few thousand lines.
Where I stop abstracting
useState call to "keep components clean" is usually a sign the component should just be smaller, not that the state needs a hook.The instinct to extract a custom hook, a wrapper component, or a shared utility the second something repeats twice is one I've had to actively unlearn. Two call sites sharing a pattern is a coincidence worth watching. Three is a pattern worth naming. Abstracting at two means guessing at a shape before you've seen enough examples to know what actually varies.
A concrete case: form field wrappers
Across three different apps I built a generic <FormField> wrapper before the third project made it clear that "label + input + error" wasn't actually the shared shape — "label + slot + error, where the slot is sometimes a custom multi-input control" was. The first two abstractions were both technically reusable and both wrong.
What actually made these apps fast
Not memoization — the recurring win was pushing work to the server that used to happen client-side: filtering, sorting, and pagination moved into the Server Component's data fetch instead of a useEffect reacting to query params. Fewer client bundles, fewer loading spinners, and a simpler mental model for the next person reading the component.1
1. The exception is genuinely interactive state — a drag-reorder list, a live filter as you type — where round-tripping to the server would make the UI feel worse, not better. ↩
Did you enjoy this article?
Recommend it — Standard Reader surfaces well-loved writing to more readers across the network.