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#0F172Adirectly - 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:
- Token generation — Convert design source (Figma, JSON, CSS) to Next.js-consumable formats automatically
- Type safety — TypeScript types generated from tokens, catching mismatches at dev time
- Build-time validation — Lint rules and build checks that fail when non-token values appear
- Runtime checks — Dev-mode warnings when components use invalid props or styles
- 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):
- Designer tells developer new hex value
- Developer updates
tailwind.config.js - Developer updates CSS variables manually
- Developer updates TypeScript types manually
- Developer updates Storybook manually
- Grep codebase for old color, manual refactor
- Hope nothing was missed
Automated flow (reliable):
- Run
npm run sync-figma-tokens(pulls from Figma API) - Run
npm run tokens:build(regenerates everything) - ESLint catches anywhere old token was hardcoded
- TypeScript catches components using deleted token
- CI validates before merge
- 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
- Choose your token source — Figma, JSON, or code-first
- Set up Style Dictionary — Automate token transformation
- Add TypeScript types — Catch errors at dev time
- Enable ESLint rules* — Block invalid token usage
- Configure CI checks — Prevent violations from merging
- 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.