Skip to main content

Building a Typography System for a Next.js Design System

Wooden letterpress type blocks arranged in rows
Apr 5, 20261 min readReact, TypeScript, Tailwind CSS, Monorepo

The audit that kicked it off

Every codebase that's been around a while picks up typographic debt, and mine had three competing systems that couldn't agree on what an <h2> should look like:

Layerh1h2h3
theme.css2.25rem / 6001.75rem / 6001.375rem / 600
mdx-components.tsxtext-2xl boldtext-lg semiboldtext-sm semibold
Page componentstext-4xl sm:text-5xl boldvariesvaries

The same "card title" pattern (text-sm font-semibold text-text-primary) showed up in 5+ files, hero headings were copy-pasted across 6 pages with identical 94-character class strings, and MDX h3 and h4 were visually indistinguishable since both were text-sm font-semibold.

Designing the variant API

I wanted a component API that encodes every approved text style as a named variant, with sensible defaults for the rendered HTML element:

<Heading variant="hero">Page Title</Heading>        // → <h1>
<Heading variant="cardTitle" as="p">Label</Heading>  // → <p>
<Text variant="body">Paragraph</Text>                // → <p>
<Text variant="meta">Apr 5, 2026</Text>              // → <span>

Each variant maps to a fixed set of Tailwind classes. The as prop overrides the default element when the semantic context calls for it, like a card title that isn't actually a heading, and className allows one-off extensions via cn() merging.

Shared-UI vs app-specific

The architecture decision that mattered was what belongs where. The shared-ui library (@danieljoffe/shared-ui) can't depend on Next.js, so any variant tied to page layout or MDX rendering had to stay in the app.

Shared-ui variants (framework-agnostic):

  • Heading: section, cardTitle, component
  • Text: body, bodyLg, cardDescription, label, meta, caption, helper, error

App-level extensions (Next.js-specific):

  • Heading: hero, detail, subtitle, mdxH1mdxH4
  • Text: subtitle

The app-level components hand the shared variants off to the shared-ui implementations and only deal with the app-specific ones locally:

export function Heading({ variant, ...props }: HeadingProps) {
  if (sharedVariants.has(variant)) {
    return <SharedHeading variant={variant} {...props} />;
  }
  // Handle app-specific variants
}

Fixing under-styled shared components

The audit turned up several shared-ui components with thin typography:

  • Modal title: a bare <h3> with zero styling classes
  • Alert title: only mb-1 mt-0, no font size or weight
  • CardTitle: text-lg in shared-ui but text-sm everywhere in the app
  • Toast: inline text-sm font-medium instead of using the system

I updated each of them to use the new typography components internally. CardTitle was the one that moved the most: switching from text-lg to text-sm via <Heading variant="cardTitle"> lined the library up with how it was actually used.

The MDX heading fix

One quiet win: MDX h4 was visually identical to h3, both text-sm font-semibold. The new mdxH4 variant uses text-xs font-medium uppercase tracking-wider, a distinct treatment that signals a different level in the content hierarchy.

// mdx-components.tsx — now uses the typography system
h3: props => <Heading variant="mdxH3" id={headingId(props)}>{props.children}</Heading>,
h4: props => <Heading variant="mdxH4" id={headingId(props)}>{props.children}</Heading>,

Results

  • 32 files changed, +439 / -150 lines
  • 0 remaining raw hero/section/cardTitle inline patterns
  • 618/618 tests passing, zero type errors
  • MDX h4 now visually distinct from h3
  • Modal, Alert, Toast, and form components all properly styled

The typography system wiped out a whole category of inconsistency. A new page now starts with <Heading variant="hero"> instead of copying a 94-character class string out of another file, and when the design moves, one variant definition updates every instance at once.