Skip to content

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.

Mac Long
May 14, 20264 min read

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.

typescript Copy
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.

TipWrite the Zod schema first, derive the TypeScript type from it — never the reverse. A hand-written type next to a validator is a promise the two will eventually disagree.

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.

Where utility classes go, by team size
PatternSolo projectTeam of 4+
Inline utilitiesFineDrifts fast
class-variance-authority variantsOverkillWorth it
Component-level compositionGood defaultGood 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

InfoA hook that wraps a single 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.

Across the AtmosphereDiscussions