Skip to content
AK.
All articles
Architecture22 July 202414 min read

Structuring a Turborepo Monorepo for Shared Design Systems Across Multiple Products

How we unified three diverging frontends — B2B portal, B2C storefront, and admin dashboard — into a single design system with shared types, tokens and components using Turborepo.

The Problem: Three Codebases Diverging

When I joined the Genomas project, three separate Next.js repositories had been built by different contractors: Novameds (B2B clinical portal), Reveal (B2C patient storefront) and the Admin Console. All three were supposed to share a design language, but in practice the Button component existed in three different forms, tokens were hardcoded strings in each repo, and any cross-cutting change required three separate PRs that inevitably drifted. The architecture was working against the product.

Choosing Turborepo

The options were a component library published to npm (slow feedback loop, versioning overhead), a git submodule (a maintenance nightmare in practice), or a true monorepo. Turborepo won because it handles the task graph correctly — it understands that building @genomas/ui must happen before building any app that consumes it — and remote caching meant CI times were the same as single-repo builds after warm-up. The setup cost was worth it at product scale.

Repository Structure

The final structure separates apps from packages cleanly. Apps are the deployable Next.js products. Packages are shared internal libraries — never deployed independently.

bash
genomas/
├── apps/
│   ├── novameds/          # B2B Next.js app
│   ├── reveal/            # B2C Next.js app
│   └── admin/             # Admin Next.js app
├── packages/
│   ├── ui/                # Shared components
│   ├── tokens/            # Design tokens (CSS variables + TS constants)
│   ├── config/            # Shared ESLint, TypeScript, Tailwind configs
│   └── types/             # Shared TypeScript types and Zod schemas
├── turbo.json
└── package.json

The Shared UI Package

The @genomas/ui package exports typed React components that are aware of theming but unopinionated about layout. Each component ships with its own .stories.tsx file and is tested with Testing Library. The package has zero runtime dependencies beyond React — Tailwind classes are compiled at the app level, not the package level, which avoids duplicated CSS.

typescript
// packages/ui/src/Button.tsx
import { type VariantProps, cva } from 'class-variance-authority';
import type { ComponentPropsWithoutRef, ElementType } from 'react';

const button = cva(
  'inline-flex items-center gap-2 font-semibold transition-colors duration-200 focus-visible:outline-2',
  {
    variants: {
      variant: {
        primary: 'bg-primary text-primary-fg hover:bg-primary-hover',
        ghost: 'text-fg hover:bg-surface',
        danger: 'bg-destructive text-white hover:bg-destructive/90',
      },
      size: {
        sm: 'px-3 py-1.5 text-sm rounded-md',
        md: 'px-4 py-2 text-sm rounded-lg',
        lg: 'px-6 py-3 text-base rounded-xl',
      },
    },
    defaultVariants: { variant: 'primary', size: 'md' },
  }
);

type Props<T extends ElementType> = {
  as?: T;
} & VariantProps<typeof button> & ComponentPropsWithoutRef<T>;

export function Button<T extends ElementType = 'button'>({
  as,
  variant,
  size,
  className,
  ...props
}: Props<T>) {
  const Tag = as ?? 'button';
  return <Tag className={button({ variant, size, className })} {...props} />;
}

Shared Design Tokens

Tokens live in @genomas/tokens and export both CSS custom properties (for runtime theming) and TypeScript constants (for computed values). This is the single source of truth — no more hardcoded hex values in component files.

typescript
// packages/tokens/src/colors.ts
export const colors = {
  ember: 'hsl(14 90% 55%)',
  emberSoft: 'hsl(14 90% 55% / 0.12)',
  fg: 'hsl(0 0% 96%)',
  fgDim: 'hsl(0 0% 72%)',
  bg: 'hsl(220 6% 6%)',
} as const;

// packages/tokens/src/index.css
:root {
  --c-ember: hsl(14 90% 55%);
  --c-fg: hsl(0 0% 96%);
  --c-bg: hsl(220 6% 6%);
}

The Outcome

Six months after migration: design changes propagate to all three products from a single PR. The Button component exists once. A type change in the API schema propagates through @genomas/types and fails CI in every affected app immediately. Build times with remote caching are faster than the single-repo builds were. The architectural cost of setting it up was paid back within two sprints. The lesson: a monorepo is not an optimisation for a single team — it is the correct default for a product family that shares a design language and a domain model.

Building something similar? I'd like to hear about it.

Start a conversation