Tutorial

Automating Design System Enforcement in Next.js Projects

A complete guide to building automated design system workflows in Next.js, from token generation to runtime validation and CI checks.

FramingUI Team10 min read

Next.js projects scale quickly—from a single developer prototype to multi-team production apps. But design consistency doesn't scale automatically. Without automation, your design system becomes a set of guidelines people forget to follow, tokens that drift out of sync, and components that diverge visually despite sharing the same Figma source.

This guide walks through building a fully automated design system workflow for Next.js that enforces consistency at build time, runtime, and in CI—without adding friction to development.

Why Manual Design Systems Fail at Scale

You start with good intentions: a design-tokens.js file, a Tailwind config, maybe a Storybook. Everyone agrees to use semantic colors. Six months later:

  • Half the codebase uses text-brand-primary, the other half uses #0F172A directly
  • New spacing values appear (gap-5) that aren't in the token scale
  • Components reference colors that were deprecated three sprints ago
  • The Figma file has moved on, but the code hasn't

The problem isn't discipline. It's that manual enforcement doesn't survive growth. Automation does.

The Automation Stack

Here's what we'll build:

  1. Token generation — Convert design source (Figma, JSON, CSS) to Next.js-consumable formats automatically
  2. Type safety — TypeScript types generated from tokens, catching mismatches at dev time
  3. Build-time validation — Lint rules and build checks that fail when non-token values appear
  4. Runtime checks — Dev-mode warnings when components use invalid props or styles
  5. CI enforcement — Automated tests that block PRs violating design system rules

Each layer catches different failure modes. Together, they make it nearly impossible to ship inconsistent UI.

Step 1: Set Up Token Source of Truth

Choose where design tokens originate. Three common patterns:

Option A: Figma Tokens (Design-Driven)

If designers own the system, sync from Figma using the Figma Tokens plugin:

npm install --save-dev style-dictionary figma-api

Create a sync script:

// scripts/sync-figma-tokens.js
const Figma = require('figma-api');
const fs = require('fs');

const api = new Figma.Api({ personalAccessToken: process.env.FIGMA_TOKEN });

async function syncTokens() {
  const file = await api.getFile(process.env.FIGMA_FILE_KEY);
  
  // Extract token variables from Figma file
  const tokens = parseVariables(file);
  
  fs.writeFileSync(
    './tokens/design-tokens.json',
    JSON.stringify(tokens, null, 2)
  );
  
  console.log('✓ Synced tokens from Figma');
}

syncTokens();

Option B: Code-First (Developer-Driven)

Define tokens in JSON, let designers reference them:

// tokens/design-tokens.json
{
  "color": {
    "brand": {
      "primary": { "value": "#0F172A" },
      "secondary": { "value": "#64748B" }
    },
    "text": {
      "primary": { "value": "#0F172A" },
      "secondary": { "value": "#64748B" }
    }
  },
  "spacing": {
    "1": { "value": "0.25rem" },
    "2": { "value": "0.5rem" },
    "4": { "value": "1rem" },
    "8": { "value": "2rem" }
  },
  "fontSize": {
    "sm": { "value": "0.875rem" },
    "base": { "value": "1rem" },
    "lg": { "value": "1.125rem" }
  }
}

Option C: Hybrid (Shared Ownership)

Use a platform like FramingUI where tokens live in a shared schema accessible to both Figma and code, automatically staying in sync.

For this guide, we'll use Option B (code-first JSON) because it's the easiest to automate.

Step 2: Transform Tokens for Next.js Consumption

Use Style Dictionary to convert raw tokens into multiple formats:

npm install --save-dev style-dictionary
// style-dictionary.config.js
module.exports = {
  source: ['tokens/design-tokens.json'],
  platforms: {
    // Generate Tailwind config
    tailwind: {
      transformGroup: 'js',
      buildPath: './',
      files: [{
        destination: 'tailwind.tokens.js',
        format: 'javascript/module',
      }],
    },
    // Generate CSS variables
    css: {
      transformGroup: 'css',
      buildPath: 'styles/',
      files: [{
        destination: 'tokens.css',
        format: 'css/variables',
      }],
    },
    // Generate TypeScript types
    typescript: {
      transformGroup: 'js',
      buildPath: 'types/',
      files: [{
        destination: 'tokens.d.ts',
        format: 'typescript/module-declarations',
      }],
    },
  },
};

Add to build pipeline:

// package.json
{
  "scripts": {
    "tokens:build": "style-dictionary build",
    "dev": "npm run tokens:build && next dev",
    "build": "npm run tokens:build && next build"
  }
}

This generates:

tailwind.tokens.js (Tailwind config):

module.exports = {
  colors: {
    brand: {
      primary: '#0F172A',
      secondary: '#64748B',
    },
    text: {
      primary: '#0F172A',
      secondary: '#64748B',
    },
  },
  spacing: {
    1: '0.25rem',
    2: '0.5rem',
    4: '1rem',
    8: '2rem',
  },
};

styles/tokens.css (CSS variables):

:root {
  --color-brand-primary: #0F172A;
  --color-brand-secondary: #64748B;
  --spacing-1: 0.25rem;
  --spacing-2: 0.5rem;
}

types/tokens.d.ts (TypeScript types):

export const tokens: {
  color: {
    brand: {
      primary: string;
      secondary: string;
    };
  };
  spacing: {
    1: string;
    2: string;
  };
};

Now import in tailwind.config.js:

const tokens = require('./tailwind.tokens.js');

module.exports = {
  content: ['./src/**/*.{js,ts,jsx,tsx}'],
  theme: {
    extend: tokens,
  },
};

Automation win: Change a token value in JSON, run npm run tokens:build, and Tailwind config, CSS vars, and TS types all update automatically.

Step 3: Add TypeScript Type Safety

Generate prop types from tokens so components can't accept invalid values:

// src/lib/design-system.ts
import { tokens } from '../types/tokens';

export type ColorToken = keyof typeof tokens.color.brand | keyof typeof tokens.color.text;
export type SpacingToken = keyof typeof tokens.spacing;
export type FontSizeToken = keyof typeof tokens.fontSize;

// Type-safe spacing function
export function spacing(value: SpacingToken): string {
  return tokens.spacing[value];
}

// Type-safe color function
export function color(category: 'brand' | 'text', shade: string): string {
  return tokens.color[category][shade];
}

Use in components:

// src/components/Button.tsx
import { ColorToken, SpacingToken } from '@/lib/design-system';

type ButtonProps = {
  variant: 'primary' | 'secondary';
  size: 'sm' | 'md' | 'lg';
  children: React.ReactNode;
};

const variantClasses: Record<string, string> = {
  primary: 'bg-brand-primary text-white',
  secondary: 'bg-brand-secondary text-white',
};

const sizeClasses: Record<string, string> = {
  sm: 'px-2 py-1 text-sm',
  md: 'px-4 py-2 text-base',
  lg: 'px-8 py-4 text-lg',
};

export function Button({ variant, size, children }: ButtonProps) {
  return (
    <button className={`${variantClasses[variant]} ${sizeClasses[size]} rounded`}>
      {children}
    </button>
  );
}

Now this fails at type-check time:

<Button variant="tertiary" size="md"> {/* Error: "tertiary" not in type */}

Step 4: Build-Time Validation with ESLint

Create custom ESLint rules that catch non-token usage:

npm install --save-dev eslint-plugin-local
// eslint-local-rules.js
module.exports = {
  'no-hardcoded-colors': {
    create(context) {
      const hexColorRegex = /#[0-9A-Fa-f]{3,6}/;
      
      return {
        Literal(node) {
          if (typeof node.value === 'string' && hexColorRegex.test(node.value)) {
            context.report({
              node,
              message: 'Use design tokens instead of hardcoded hex colors',
            });
          }
        },
      };
    },
  },
  'no-arbitrary-spacing': {
    create(context) {
      return {
        JSXAttribute(node) {
          if (node.name.name === 'className') {
            const value = node.value.value;
            // Check for arbitrary Tailwind values like p-[23px]
            if (/\[[\d.]+(?:px|rem)\]/.test(value)) {
              context.report({
                node,
                message: 'Use spacing tokens instead of arbitrary values',
              });
            }
          }
        },
      };
    },
  },
};

Add to .eslintrc.js:

module.exports = {
  plugins: ['local'],
  rules: {
    'local/no-hardcoded-colors': 'error',
    'local/no-arbitrary-spacing': 'error',
  },
};

Now this fails at lint time:

<div style={{ color: '#0F172A' }}> {/* Error: Use tokens */}
<div className="p-[23px]"> {/* Error: Use spacing tokens */}

Step 5: Runtime Validation in Development

Add dev-mode checks that warn when invalid props slip through:

// src/lib/validate-props.ts
import { tokens } from '../types/tokens';

export function validateColor(value: string, componentName: string) {
  if (process.env.NODE_ENV !== 'development') return;
  
  const validColors = Object.values(tokens.color).flatMap(Object.values);
  
  if (!validColors.includes(value)) {
    console.warn(
      `[Design System] Invalid color "${value}" in ${componentName}. ` +
      `Use tokens from tokens.color instead.`
    );
  }
}

export function validateSpacing(value: string, componentName: string) {
  if (process.env.NODE_ENV !== 'development') return;
  
  const validSpacing = Object.values(tokens.spacing);
  
  if (!validSpacing.includes(value)) {
    console.warn(
      `[Design System] Invalid spacing "${value}" in ${componentName}. ` +
      `Use tokens from tokens.spacing instead.`
    );
  }
}

Use in components:

export function Card({ padding = '1rem', bgColor = '#FFF' }: CardProps) {
  validateSpacing(padding, 'Card');
  validateColor(bgColor, 'Card');
  
  return <div style={{ padding, backgroundColor: bgColor }}>...</div>;
}

Console output in dev:

[Design System] Invalid color "#FFF" in Card. Use tokens from tokens.color instead.

Step 6: CI Enforcement

Add automated checks to your GitHub Actions / CI pipeline:

# .github/workflows/design-system.yml
name: Design System Checks

on: [pull_request]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 18
      
      - run: npm ci
      - run: npm run tokens:build
      
      # Type check
      - run: npm run type-check
      
      # Lint for token violations
      - run: npm run lint
      
      # Check that no hardcoded values exist
      - name: Scan for hardcoded colors
        run: |
          if grep -r "#[0-9A-Fa-f]\{6\}" src/ --include="*.tsx" --include="*.ts"; then
            echo "Found hardcoded hex colors. Use design tokens instead."
            exit 1
          fi
      
      # Validate token schema hasn't broken
      - name: Validate tokens
        run: node scripts/validate-tokens.js

Create scripts/validate-tokens.js:

const tokens = require('../tokens/design-tokens.json');

function validateTokenStructure(obj, path = '') {
  for (const [key, value] of Object.entries(obj)) {
    const fullPath = path ? `${path}.${key}` : key;
    
    if (value.value === undefined && typeof value === 'object') {
      validateTokenStructure(value, fullPath);
    } else if (value.value === undefined) {
      throw new Error(`Token at ${fullPath} missing "value" property`);
    }
  }
}

try {
  validateTokenStructure(tokens);
  console.log('✓ All tokens valid');
} catch (error) {
  console.error('✗ Token validation failed:', error.message);
  process.exit(1);
}

CI now blocks PRs that:

  • Use hardcoded colors or spacing
  • Break TypeScript types
  • Violate ESLint token rules
  • Have malformed token definitions

Step 7: Automate Documentation

Generate Storybook docs automatically from tokens:

// src/stories/DesignTokens.stories.tsx
import { tokens } from '../types/tokens';

export default {
  title: 'Design System/Tokens',
};

export const Colors = () => (
  <div className="grid gap-4">
    {Object.entries(tokens.color.brand).map(([name, value]) => (
      <div key={name} className="flex items-center gap-4">
        <div className="w-16 h-16 rounded" style={{ backgroundColor: value }} />
        <div>
          <div className="font-mono text-sm">{name}</div>
          <div className="text-xs text-gray-500">{value}</div>
        </div>
      </div>
    ))}
  </div>
);

export const Spacing = () => (
  <div className="grid gap-4">
    {Object.entries(tokens.spacing).map(([name, value]) => (
      <div key={name} className="flex items-center gap-4">
        <div className="bg-blue-500" style={{ width: value, height: '2rem' }} />
        <div className="font-mono text-sm">
          {name}: {value}
        </div>
      </div>
    ))}
  </div>
);

Automation win: Token docs update automatically when tokens change. No manual Storybook writing.

Real-World Example: Token Update Flow

Designer updates brand primary color in Figma.

Manual flow (error-prone):

  1. Designer tells developer new hex value
  2. Developer updates tailwind.config.js
  3. Developer updates CSS variables manually
  4. Developer updates TypeScript types manually
  5. Developer updates Storybook manually
  6. Grep codebase for old color, manual refactor
  7. Hope nothing was missed

Automated flow (reliable):

  1. Run npm run sync-figma-tokens (pulls from Figma API)
  2. Run npm run tokens:build (regenerates everything)
  3. ESLint catches anywhere old token was hardcoded
  4. TypeScript catches components using deleted token
  5. CI validates before merge
  6. Storybook updates automatically

Time saved: ~2 hours per change. Errors prevented: many.

When Automation Is Worth It

This level of automation makes sense when:

  • Team size > 3 — Coordination costs outweigh setup time
  • Design changes frequently — More than once per sprint
  • Multiple repos share the design system — Monorepo or package-based
  • Non-designers contribute UI code — AI, contractors, backend devs

It's overkill if you're a solo developer working on a side project with a stable design.

Reducing Boilerplate with Tools

Setting up all these scripts, ESLint rules, and CI checks is a one-time cost, but maintaining them isn't free. Tools like FramingUI bundle this entire automation stack:

  • Token sync from Figma or JSON
  • Automatic Tailwind config generation
  • Type-safe component props
  • Built-in ESLint rules
  • CI validation scripts

You define tokens once, and the framework handles generation, validation, and enforcement. If you're building this workflow from scratch, expect 1-2 weeks of setup. If you're using a pre-built system, expect 1-2 hours.

Next Steps

  1. Choose your token source — Figma, JSON, or code-first
  2. Set up Style Dictionary — Automate token transformation
  3. Add TypeScript types — Catch errors at dev time
  4. Enable ESLint rules* — Block invalid token usage
  5. Configure CI checks — Prevent violations from merging
  6. Document tokens — Auto-generate Storybook or docs site

Once the automation is in place, your design system enforces itself. Developers can't accidentally use wrong colors. Designers can update tokens without touching code. CI blocks inconsistencies before they ship.

Design at scale stops being a coordination problem and becomes a build pipeline.

Ready to build with FramingUI?

Build consistent UI with AI-ready design tokens. No more hallucinated colors or spacing.

Try FramingUI
Share

Related Posts