Skip to main content

From Wrapper Hell to One Source of Truth

Neatly organized colorful building blocks arranged in a grid pattern
Apr 4, 20263 min readReact, TypeScript, Tailwind CSS, Monorepo

The problem

The portfolio site had grown organically. There was a shared-ui library with generic components (Button, Alert, Spinner), but the app also had its own kit/ directory full of wrappers that re-exported those same components with "app-specific" additions bolted on. The result was a layered mess:

  • Typography wrappers: kit/Heading.tsx imported shared-ui/Heading, added 7 app variants (hero, subtitle, mdxH1mdxH4), and duplicated the entire rendering logic: polymorphic tag selection, cn() class merging, variant-to-style mapping. kit/Text.tsx did the same for a single variant (subtitle).
  • Layout wrappers: kit/PageLayout.tsx wrapped shared-ui/PageContainer to add as='main' and spacing. kit/Section.tsx wrapped shared-ui/Section to flip two default props.
  • Component wrappers: kit/Spinner.tsx, kit/ErrorAlert.tsx, kit/SectionLabel.tsx, and others re-exported shared-ui equivalents with minor prop adaptations.

Every wrapper was its own little tax: parallel variant sets to keep in sync, duplicated type definitions, and constant confusion about where to import from. Consumers had to know which layer owned which variant, and pages ended up mixing @/components/kit and @danieljoffe/shared-ui imports with no real logic to it.

The principles

Before I wrote any code, I set three rules:

  1. shared-ui is the single source of truth for all reusable UI primitives: typography, layout, and generic components. It depends only on React and Tailwind CSS. No Next.js APIs.
  2. No wrappers that only add variants. If a component needs more variants, add them to shared-ui directly. Kit exists only for components that compose shared-ui with Next.js-specific APIs (Link, useRouter, next/image).
  3. Defaults should match the dominant usage pattern. If 15 out of 15 pages use the same spacing, bake it in as the default. Don't make every consumer repeat the same props.

The typography system

The first target was the Heading/Text split. The kit wrappers kept a sharedVariants set around to decide, at runtime, whether to delegate to shared-ui or render locally: a runtime check standing in for a compile-time concern.

The fix was simple: move every variant into shared-ui.

Heading: 10 variants

const variantStyles: Record<HeadingVariant, string> = {
  hero: 'text-4xl sm:text-5xl font-bold tracking-tight leading-[1.1]',
  detail: 'text-3xl sm:text-4xl font-bold tracking-tight',
  subtitle: 'text-2xl font-bold tracking-tight',
  section: 'text-2xl sm:text-3xl font-bold tracking-tight',
  cardTitle: 'text-sm font-semibold',
  component: 'text-lg font-semibold',
  mdxH1: 'text-2xl font-bold tracking-tight mb-6',
  mdxH2: 'text-lg font-semibold mt-12 mb-4 scroll-mt-20',
  mdxH3: 'text-sm font-semibold mt-8 mb-3 scroll-mt-20',
  mdxH4: 'text-xs font-medium mt-6 mb-2 uppercase tracking-wider',
};

Each variant maps to a sensible default HTML element (heroh1, cardTitleh3), and you can override it with the as prop. The consumer picks the visual intent; the component takes care of the semantics.

Text: 10 variants

const variantStyles: Record<TextVariant, string> = {
  body: 'text-sm text-text-secondary leading-relaxed',
  bodyLg: 'text-base text-text-secondary leading-relaxed',
  subtitle: 'text-lg text-text-secondary leading-relaxed',
  cardDescription: 'text-sm text-text-secondary leading-relaxed',
  detail: 'text-xs text-text-secondary',
  label: 'text-xs font-semibold uppercase tracking-wider text-text-tertiary',
  meta: 'text-xs text-text-tertiary',
  caption: 'text-sm text-text-tertiary',
  helper: 'text-sm text-text-tertiary',
  error: 'text-sm text-error',
};

With the wrappers gone, every consumer imports directly from shared-ui:

import { Heading } from '@danieljoffe/shared-ui/Heading';
import { Text } from '@danieljoffe/shared-ui/Text';

No kit layer, no delegation, no variant sets to keep in sync.

Layout primitives with defaults

The second realization came from staring at the 15 pages that all repeated className='py-16 lg:py-24 space-y-24' on their PageLayout. When every single consumer passes the same props, those aren't options anymore; they're the default.

PageLayout

export function PageLayout({ children, wide = false, className, ...rest }) {
  return (
    <PageContainer
      as='main'
      id='main-content'
      size={wide ? 'md' : 'sm'}
      className={cn('py-16 lg:py-24 space-y-24', className)}
      {...rest}
    >
      {children}
    </PageContainer>
  );
}

Now a page is just:

<PageLayout>
  <Section>...</Section>
  <Section>...</Section>
</PageLayout>

No props needed for the common case. wide toggles to a wider container for data-heavy pages, and a custom className merges with the defaults via cn().

Section

The shared-ui Section had defaults that didn't match reality: center=true and overflow='hidden', both of which every page overrode anyway. So I flipped the defaults and folded in the base horizontal padding every section needed:

export function Section({
  children,
  padding = 'none',
  center = false,
  overflow = 'visible',
  className,
  ...rest
}) {
  return (
    <section
      className={cn('relative px-6 lg:px-0', /* variant classes */, className)}
      {...rest}
    >
      {children}
    </section>
  );
}

Component promotion

With the typography and layout systems consolidated, the leftover kit components sorted themselves into two piles:

Moved to shared-ui (no Next.js deps)

  • Kbd: keyboard shortcut badge
  • GridBg: decorative CSS grid background
  • StructuredData: JSON-LD script injector
  • SectionLabel: icon + label + decorative rule
  • CTACard: card with heading, description, and action slot

Each one was a pure React + Tailwind component that had been sitting in kit purely out of habit. Moving them to shared-ui made them available to any app in the monorepo.

Kept in kit (Next.js dependent)

  • PostCard: uses next/link for client-side navigation
  • CoverImage: uses next/image for optimization
  • CompanyLogo: uses next/image

These genuinely compose Next.js APIs with shared-ui primitives, which is exactly what kit is for.

Deleted entirely

  • kit/Heading.tsx, kit/Text.tsx: replaced by shared-ui variants
  • kit/PageLayout.tsx, kit/Section.tsx: replaced by shared-ui with better defaults
  • kit/ErrorAlert.tsx: replaced by <Alert variant='error'> + inline retry button
  • kit/Spinner.tsx: direct shared-ui import (labelaria-label)
  • ClientBadge, ClientSkeleton: React 19 ref prop pattern eliminated the need for client wrappers around server-compatible components

Enforcement

A design system without enforcement is just a strongly worded suggestion. So I added a custom ESLint rule that bans raw <h1> through <h6> elements in JSX and points developers at the Heading component instead. The rule catches violations at lint time, well before they reach code review.

Results

  • 176 files changed, -4,282 / +2,273 lines, a net reduction of ~2,000 lines
  • 31 consumer files updated to import directly from shared-ui
  • 11 kit wrapper files deleted
  • Zero new dependencies: shared-ui still only requires React + Tailwind
  • All 1,266 unit tests pass. All 695 E2E tests pass. Zero hydration errors.

Takeaways

Wrappers that only add variants are a code smell. If the wrapper doesn't add real behavior (hooks, state, API calls), those variants belong in the library. The "app-specific" label was just an excuse to avoid touching the shared layer.

Defaults should encode the dominant pattern. When every consumer overrides a default, the default is the thing that's wrong. Flipping center=true to center=false in Section deleted 15 identical prop overrides in one go.

The Rule of Three applies to deletion too. If three wrappers exist just to flip a prop, delete all three and fix the source instead.

Enforce at the tool level. A lint rule catches far more than a convention doc ever will; the no-raw-headings rule had already caught regressions in the same session I introduced it.