Frontend as a Platform: When Your Team Becomes an Internal Infrastructure Team
Frontend as a Platform: When Your Team Becomes an Internal Infrastructure Team
At some point, your frontend team stops building features and starts building the tools that let other teams build features. You become infrastructure. Your users aren't customers—they're other engineers. Your product isn't the app—it's the design system, the CLI, the component library, the build tooling.
This transition is jarring. The skills that made you a great product engineer—shipping fast, iterating on user feedback, moving on to the next feature—become liabilities. Platform engineering rewards different things: stability, documentation, backwards compatibility, and saying "no" more than you say "yes."
This guide covers building and operating a frontend platform team.
The Platform Team Mandate
┌─────────────────────────────────────────────────────────────────────┐
│ PRODUCT TEAM vs PLATFORM TEAM │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ PRODUCT TEAM PLATFORM TEAM │
│ ════════════ ═════════════ │
│ │
│ Users: Customers Users: Engineers │
│ Success: Feature adoption Success: Developer productivity │
│ Metric: User engagement Metric: Time to ship │
│ Pace: Fast iteration Pace: Deliberate, stable │
│ Failure: Bad UX (recoverable) Failure: Breaking change (costly)│
│ │
│ VALUES │
│ ────── │
│ • Ship fast • Ship right │
│ • Experiment • Standardize │
│ • Optimize for now • Optimize for scale │
│ • Break things, learn • Don't break things │
│ • Direct user feedback • Engineer feedback (filtered) │
│ │
│ OUTPUTS │
│ ─────── │
│ • Features • Design system │
│ • Pages • Component library │
│ • User flows • CLI tooling │
│ • Experiments • Build infrastructure │
│ • Analytics • Templates & scaffolding │
│ │
│ SUPPORT MODEL │
│ ───────────── │
│ • Owns the feature • Enables feature teams │
│ • Fixes own bugs • Responds to bug reports │
│ • No SLA needed • SLA with consumers │
│ │
└─────────────────────────────────────────────────────────────────────┘
The Platform Stack
A mature frontend platform provides multiple layers:
┌─────────────────────────────────────────────────────────────────────┐
│ FRONTEND PLATFORM STACK │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Layer 5: GOVERNANCE │ │
│ │ • Contribution guidelines │ │
│ │ • RFC process │ │
│ │ • Architecture decision records │ │
│ │ • Deprecation policies │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ▲ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Layer 4: DEVELOPER EXPERIENCE │ │
│ │ • CLI tools (scaffolding, generators) │ │
│ │ • IDE extensions │ │
│ │ • Documentation site │ │
│ │ • Storybook / Component playground │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ▲ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Layer 3: DESIGN SYSTEM │ │
│ │ • Component library │ │
│ │ • Design tokens │ │
│ │ • Icons & assets │ │
│ │ • Layout primitives │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ▲ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Layer 2: SHARED LIBRARIES │ │
│ │ • Data fetching (API clients) │ │
│ │ • State management utilities │ │
│ │ • Auth / session handling │ │
│ │ • Analytics / logging │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ▲ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Layer 1: BUILD INFRASTRUCTURE │ │
│ │ • Bundler configuration │ │
│ │ • CI/CD pipelines │ │
│ │ • Testing infrastructure │ │
│ │ • Linting / formatting │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Feature teams build on top of this stack │
│ Platform team maintains all layers │
│ │
└─────────────────────────────────────────────────────────────────────┘
Design Systems at Scale
Architecture
┌─────────────────────────────────────────────────────────────────────┐
│ DESIGN SYSTEM ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ packages/ │
│ ├── tokens/ ← Design tokens (foundation) │
│ │ ├── colors.json │
│ │ ├── spacing.json │
│ │ ├── typography.json │
│ │ └── build/ Generated CSS vars, TS constants │
│ │ │
│ ├── icons/ ← Icon library │
│ │ ├── svg/ │
│ │ ├── react/ Generated React components │
│ │ └── sprite.svg Combined sprite sheet │
│ │ │
│ ├── primitives/ ← Unstyled, accessible primitives │
│ │ ├── Button/ │
│ │ ├── Dialog/ │
│ │ ├── Select/ │
│ │ └── ... Built on Radix/HeadlessUI │
│ │ │
│ ├── components/ ← Styled, opinionated components │
│ │ ├── Button/ │
│ │ │ ├── Button.tsx │
│ │ │ ├── Button.styles.ts │
│ │ │ ├── Button.test.tsx │
│ │ │ ├── Button.stories.tsx │
│ │ │ └── index.ts │
│ │ └── ... │
│ │ │
│ ├── patterns/ ← Composed patterns │
│ │ ├── DataTable/ Multi-component compositions │
│ │ ├── FormField/ │
│ │ └── PageLayout/ │
│ │ │
│ └── theme/ ← Theme provider & utilities │
│ ├── ThemeProvider.tsx │
│ ├── useTheme.ts │
│ └── themes/ │
│ ├── light.ts │
│ └── dark.ts │
│ │
└─────────────────────────────────────────────────────────────────────┘
Component API Design Principles
// packages/components/Button/Button.tsx
/**
* PRINCIPLE 1: Props should be explicit, not magical
*/
// ❌ BAD: Magic props that do multiple things
interface BadButtonProps {
type?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'link';
// What does 'link' mean? Different styling AND behavior?
}
// ✅ GOOD: Separate concerns
interface ButtonProps {
/** Visual style variant */
variant?: 'solid' | 'outline' | 'ghost';
/** Color scheme */
colorScheme?: 'primary' | 'neutral' | 'danger';
/** Render as a different element (for links styled as buttons) */
as?: 'button' | 'a';
}
/**
* PRINCIPLE 2: Composition over configuration
*/
// ❌ BAD: Props for every possible configuration
interface BadButtonProps {
leftIcon?: ReactNode;
rightIcon?: ReactNode;
isLoading?: boolean;
loadingText?: string;
loadingSpinnerPosition?: 'left' | 'right';
}
// ✅ GOOD: Composable children
interface ButtonProps {
children: ReactNode;
isLoading?: boolean;
}
// Usage:
<Button>
<Icon name="download" />
Download
</Button>
<Button isLoading>
<Spinner />
Downloading...
</Button>
/**
* PRINCIPLE 3: Sensible defaults, full override capability
*/
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'solid' | 'outline' | 'ghost';
colorScheme?: 'primary' | 'neutral' | 'danger';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
// All native button props available via spread
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = 'solid',
colorScheme = 'primary',
size = 'md',
isLoading = false,
disabled,
className,
children,
...props // Native props pass through
},
ref
) => {
return (
<button
ref={ref}
className={cn(
buttonStyles({ variant, colorScheme, size }),
className // User can add classes
)}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? <Spinner size={size} /> : children}
</button>
);
}
);
/**
* PRINCIPLE 4: Accessible by default
*/
// Component handles ARIA automatically
export const Dialog = ({ open, onClose, title, children }: DialogProps) => {
return (
<RadixDialog.Root open={open} onOpenChange={onClose}>
<RadixDialog.Portal>
<RadixDialog.Overlay className={overlayStyles} />
<RadixDialog.Content
className={contentStyles}
aria-describedby={undefined} // Only if no description
>
<RadixDialog.Title className={titleStyles}>
{title}
</RadixDialog.Title>
{children}
<RadixDialog.Close asChild>
<IconButton
aria-label="Close dialog"
icon={<CloseIcon />}
className={closeButtonStyles}
/>
</RadixDialog.Close>
</RadixDialog.Content>
</RadixDialog.Portal>
</RadixDialog.Root>
);
};
Design Tokens Pipeline
// packages/tokens/build.ts
import StyleDictionary from 'style-dictionary';
// Source tokens (design tool export or manual)
const tokens = {
color: {
primary: {
50: { value: '#eff6ff' },
100: { value: '#dbeafe' },
500: { value: '#3b82f6' },
600: { value: '#2563eb' },
900: { value: '#1e3a8a' },
},
neutral: {
0: { value: '#ffffff' },
50: { value: '#f9fafb' },
900: { value: '#111827' },
1000: { value: '#000000' },
},
},
spacing: {
0: { value: '0' },
1: { value: '0.25rem' },
2: { value: '0.5rem' },
4: { value: '1rem' },
8: { value: '2rem' },
},
typography: {
fontFamily: {
sans: { value: 'Inter, system-ui, sans-serif' },
mono: { value: 'JetBrains Mono, monospace' },
},
fontSize: {
sm: { value: '0.875rem' },
base: { value: '1rem' },
lg: { value: '1.125rem' },
xl: { value: '1.25rem' },
},
},
radius: {
none: { value: '0' },
sm: { value: '0.25rem' },
md: { value: '0.375rem' },
lg: { value: '0.5rem' },
full: { value: '9999px' },
},
};
// Build to multiple formats
StyleDictionary.extend({
source: ['tokens/**/*.json'],
platforms: {
// CSS Custom Properties
css: {
transformGroup: 'css',
buildPath: 'build/css/',
files: [
{
destination: 'tokens.css',
format: 'css/variables',
},
],
},
// TypeScript constants
ts: {
transformGroup: 'js',
buildPath: 'build/ts/',
files: [
{
destination: 'tokens.ts',
format: 'javascript/es6',
},
],
},
// Tailwind config
tailwind: {
transformGroup: 'js',
buildPath: 'build/tailwind/',
files: [
{
destination: 'tailwind.config.js',
format: 'tailwind/config',
},
],
},
// Figma (for design tool sync)
figma: {
transformGroup: 'js',
buildPath: 'build/figma/',
files: [
{
destination: 'figma-tokens.json',
format: 'figma/tokens',
},
],
},
},
}).buildAllPlatforms();
// Output: build/css/tokens.css
/*
:root {
--color-primary-50: #eff6ff;
--color-primary-500: #3b82f6;
--spacing-1: 0.25rem;
--spacing-4: 1rem;
--font-family-sans: Inter, system-ui, sans-serif;
--radius-md: 0.375rem;
}
*/
// Output: build/ts/tokens.ts
/*
export const colorPrimary50 = '#eff6ff';
export const colorPrimary500 = '#3b82f6';
export const spacing1 = '0.25rem';
export const spacing4 = '1rem';
*/
CLI Tooling and Scaffolding
CLI Architecture
// packages/cli/src/index.ts
import { Command } from 'commander';
import { scaffold } from './commands/scaffold';
import { generate } from './commands/generate';
import { lint } from './commands/lint';
import { upgrade } from './commands/upgrade';
const program = new Command();
program
.name('platform')
.description('Frontend Platform CLI')
.version('1.0.0');
program
.command('scaffold <type>')
.description('Scaffold a new project or feature')
.option('-n, --name <name>', 'Name of the project/feature')
.option('-t, --template <template>', 'Template to use')
.option('--dry-run', 'Show what would be created')
.action(scaffold);
program
.command('generate <type>')
.alias('g')
.description('Generate component, hook, or service')
.option('-n, --name <name>', 'Name')
.option('-p, --path <path>', 'Path to generate in')
.action(generate);
program
.command('lint')
.description('Run platform linting rules')
.option('--fix', 'Automatically fix issues')
.action(lint);
program
.command('upgrade')
.description('Upgrade platform packages')
.option('--check', 'Check for updates without applying')
.action(upgrade);
program.parse();
Scaffolding Templates
// packages/cli/src/commands/scaffold.ts
import { mkdir, writeFile } from 'fs/promises';
import { join } from 'path';
import Handlebars from 'handlebars';
import ora from 'ora';
import prompts from 'prompts';
interface ScaffoldOptions {
name?: string;
template?: string;
dryRun?: boolean;
}
const templates = {
'feature': {
description: 'Full feature module with components, hooks, and API',
files: [
{ path: '{{name}}/index.ts', template: 'feature/index.ts.hbs' },
{ path: '{{name}}/components/index.ts', template: 'feature/components-index.ts.hbs' },
{ path: '{{name}}/components/{{pascalName}}View.tsx', template: 'feature/view.tsx.hbs' },
{ path: '{{name}}/hooks/index.ts', template: 'feature/hooks-index.ts.hbs' },
{ path: '{{name}}/hooks/use{{pascalName}}.ts', template: 'feature/hook.ts.hbs' },
{ path: '{{name}}/api/index.ts', template: 'feature/api.ts.hbs' },
{ path: '{{name}}/types.ts', template: 'feature/types.ts.hbs' },
],
},
'component': {
description: 'Single component with tests and stories',
files: [
{ path: '{{pascalName}}/index.ts', template: 'component/index.ts.hbs' },
{ path: '{{pascalName}}/{{pascalName}}.tsx', template: 'component/component.tsx.hbs' },
{ path: '{{pascalName}}/{{pascalName}}.test.tsx', template: 'component/test.tsx.hbs' },
{ path: '{{pascalName}}/{{pascalName}}.stories.tsx', template: 'component/stories.tsx.hbs' },
],
},
'app': {
description: 'New Next.js application',
files: [
// Full app structure
],
},
};
export async function scaffold(type: string, options: ScaffoldOptions) {
const template = templates[type as keyof typeof templates];
if (!template) {
console.error(`Unknown template type: ${type}`);
console.log('Available types:', Object.keys(templates).join(', '));
process.exit(1);
}
// Interactive prompts if options not provided
const answers = await prompts([
{
type: options.name ? null : 'text',
name: 'name',
message: `What is the ${type} name?`,
validate: (value) => /^[a-z][a-z0-9-]*$/.test(value) || 'Use lowercase with dashes',
},
]);
const name = options.name || answers.name;
const pascalName = toPascalCase(name);
const context = {
name,
pascalName,
camelName: toCamelCase(name),
kebabName: name,
year: new Date().getFullYear(),
};
const spinner = ora('Generating files...').start();
for (const file of template.files) {
const filePath = Handlebars.compile(file.path)(context);
const templateContent = await loadTemplate(file.template);
const content = Handlebars.compile(templateContent)(context);
if (options.dryRun) {
console.log(`Would create: ${filePath}`);
continue;
}
const fullPath = join(process.cwd(), filePath);
await mkdir(join(fullPath, '..'), { recursive: true });
await writeFile(fullPath, content);
spinner.text = `Created ${filePath}`;
}
spinner.succeed(`Scaffolded ${type}: ${name}`);
// Post-scaffold instructions
console.log('\nNext steps:');
console.log(` cd ${name}`);
console.log(' npm install');
console.log(' npm run dev');
}
// Template example: feature/view.tsx.hbs
/*
import { FC } from 'react';
import { use{{pascalName}} } from '../hooks';
import type { {{pascalName}}Props } from '../types';
export const {{pascalName}}View: FC<{{pascalName}}Props> = ({ id }) => {
const { data, isLoading, error } = use{{pascalName}}(id);
if (isLoading) {
return <Skeleton />;
}
if (error) {
return <ErrorMessage error={error} />;
}
return (
<div>
{/* {{pascalName}} content */}
</div>
);
};
*/
Code Generation
// packages/cli/src/commands/generate.ts
import { Project, SyntaxKind } from 'ts-morph';
interface GenerateOptions {
name: string;
path?: string;
}
export async function generate(type: string, options: GenerateOptions) {
switch (type) {
case 'component':
await generateComponent(options);
break;
case 'hook':
await generateHook(options);
break;
case 'api':
await generateApiClient(options);
break;
}
}
async function generateComponent(options: GenerateOptions) {
const project = new Project();
const componentName = toPascalCase(options.name);
const path = options.path || 'src/components';
// Generate component file
const componentFile = project.createSourceFile(
`${path}/${componentName}/${componentName}.tsx`,
`
import { forwardRef } from 'react';
import { cn } from '@company/utils';
import type { ${componentName}Props } from './types';
import styles from './${componentName}.module.css';
export const ${componentName} = forwardRef<HTMLDivElement, ${componentName}Props>(
({ className, children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(styles.root, className)}
{...props}
>
{children}
</div>
);
}
);
${componentName}.displayName = '${componentName}';
`.trim()
);
// Generate types file
const typesFile = project.createSourceFile(
`${path}/${componentName}/types.ts`,
`
import type { HTMLAttributes, ReactNode } from 'react';
export interface ${componentName}Props extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
`.trim()
);
// Generate index file
const indexFile = project.createSourceFile(
`${path}/${componentName}/index.ts`,
`
export { ${componentName} } from './${componentName}';
export type { ${componentName}Props } from './types';
`.trim()
);
// Generate test file
const testFile = project.createSourceFile(
`${path}/${componentName}/${componentName}.test.tsx`,
`
import { render, screen } from '@testing-library/react';
import { ${componentName} } from './${componentName}';
describe('${componentName}', () => {
it('renders children', () => {
render(<${componentName}>Content</${componentName}>);
expect(screen.getByText('Content')).toBeInTheDocument();
});
it('forwards ref', () => {
const ref = { current: null };
render(<${componentName} ref={ref}>Content</${componentName}>);
expect(ref.current).toBeInstanceOf(HTMLDivElement);
});
it('applies custom className', () => {
render(<${componentName} className="custom">Content</${componentName}>);
expect(screen.getByText('Content')).toHaveClass('custom');
});
});
`.trim()
);
await project.save();
console.log(`Generated component: ${componentName}`);
console.log(` ${path}/${componentName}/${componentName}.tsx`);
console.log(` ${path}/${componentName}/types.ts`);
console.log(` ${path}/${componentName}/index.ts`);
console.log(` ${path}/${componentName}/${componentName}.test.tsx`);
}
Documentation as Product
Documentation Site Architecture
// Documentation structure
docs/
├── app/
│ ├── page.tsx // Landing page
│ ├── getting-started/
│ │ ├── page.mdx // Getting started guide
│ │ └── installation/
│ │ └── page.mdx
│ ├── components/
│ │ ├── page.tsx // Component index
│ │ └── [component]/
│ │ └── page.tsx // Individual component docs
│ ├── patterns/
│ │ └── page.mdx
│ ├── tokens/
│ │ └── page.tsx // Token browser
│ └── api/
│ └── page.tsx // API reference
├── components/
│ ├── ComponentDoc.tsx // Component documentation layout
│ ├── PropsTable.tsx // Auto-generated props table
│ ├── CodeBlock.tsx // Syntax-highlighted code
│ └── LiveEditor.tsx // Interactive playground
└── lib/
├── getComponentDocs.ts // Extract docs from source
└── getPropsFromSource.ts // Parse TypeScript for props
// packages/docs/lib/getPropsFromSource.ts
import { Project, SyntaxKind, TypeAliasDeclaration } from 'ts-morph';
interface PropDefinition {
name: string;
type: string;
required: boolean;
defaultValue?: string;
description?: string;
}
export async function getPropsFromSource(
componentPath: string,
propsTypeName: string
): Promise<PropDefinition[]> {
const project = new Project();
const sourceFile = project.addSourceFileAtPath(componentPath);
// Find the props interface/type
const propsType = sourceFile.getTypeAlias(propsTypeName)
|| sourceFile.getInterface(propsTypeName);
if (!propsType) {
return [];
}
const props: PropDefinition[] = [];
// Extract properties
const type = propsType.getType();
const properties = type.getProperties();
for (const prop of properties) {
const declarations = prop.getDeclarations();
const declaration = declarations[0];
if (!declaration) continue;
const jsDoc = declaration.getJsDocs?.()[0];
const description = jsDoc?.getDescription?.().trim();
props.push({
name: prop.getName(),
type: prop.getTypeAtLocation(declaration).getText(),
required: !prop.isOptional(),
description,
});
}
return props;
}
Auto-Generated Component Documentation
// packages/docs/components/ComponentDoc.tsx
import { getPropsFromSource } from '@/lib/getPropsFromSource';
import { PropsTable } from './PropsTable';
import { LiveEditor } from './LiveEditor';
import * as Components from '@company/design-system';
interface ComponentDocProps {
name: string;
description: string;
examples: Array<{
title: string;
code: string;
}>;
}
export async function ComponentDoc({ name, description, examples }: ComponentDocProps) {
// Auto-extract props from source
const props = await getPropsFromSource(
`../packages/components/src/${name}/${name}.tsx`,
`${name}Props`
);
const Component = Components[name as keyof typeof Components];
return (
<div className="space-y-12">
{/* Header */}
<header>
<h1 className="text-4xl font-bold">{name}</h1>
<p className="text-xl text-gray-600 mt-2">{description}</p>
</header>
{/* Import */}
<section>
<h2>Import</h2>
<CodeBlock language="tsx">
{`import { ${name} } from '@company/design-system';`}
</CodeBlock>
</section>
{/* Live Examples */}
<section>
<h2>Examples</h2>
<div className="space-y-8">
{examples.map((example, i) => (
<div key={i}>
<h3>{example.title}</h3>
<LiveEditor
code={example.code}
scope={{ ...Components }}
/>
</div>
))}
</div>
</section>
{/* Props Table (auto-generated) */}
<section>
<h2>Props</h2>
<PropsTable props={props} />
</section>
{/* Accessibility */}
<section>
<h2>Accessibility</h2>
<AccessibilityChecklist component={name} />
</section>
</div>
);
}
// packages/docs/components/PropsTable.tsx
export function PropsTable({ props }: { props: PropDefinition[] }) {
return (
<table className="w-full">
<thead>
<tr>
<th className="text-left">Prop</th>
<th className="text-left">Type</th>
<th className="text-left">Default</th>
<th className="text-left">Description</th>
</tr>
</thead>
<tbody>
{props.map((prop) => (
<tr key={prop.name}>
<td>
<code>{prop.name}</code>
{prop.required && <span className="text-red-500">*</span>}
</td>
<td>
<code className="text-sm text-purple-600">{prop.type}</code>
</td>
<td>
{prop.defaultValue ? (
<code>{prop.defaultValue}</code>
) : (
<span className="text-gray-400">-</span>
)}
</td>
<td>{prop.description}</td>
</tr>
))}
</tbody>
</table>
);
}
Governance Models
RFC Process
<!-- .github/RFC_TEMPLATE.md -->
# RFC: [Title]
## Summary
One paragraph explanation of the proposal.
## Motivation
Why are we doing this? What problem does it solve?
## Detailed Design
### API Changes
```tsx
// Before
<Button type="primary">Click me</Button>
// After
<Button variant="solid" colorScheme="primary">Click me</Button>
Migration Path
How do existing consumers migrate?
Breaking Changes
List all breaking changes.
Alternatives Considered
What other approaches were considered?
Adoption Strategy
How will this be rolled out?
Unresolved Questions
What questions remain?
Checklist
- API design reviewed
- Accessibility reviewed
- Performance impact assessed
- Migration guide written
- Documentation updated
### Contribution Guidelines
```markdown
<!-- CONTRIBUTING.md -->
# Contributing to the Design System
## Before You Contribute
1. **Check existing issues** - Someone may have already proposed this
2. **Open a discussion** - For new components or major changes
3. **RFC required** - For breaking changes or new APIs
## Contribution Types
### Bug Fixes
1. Open issue with reproduction
2. Fork and create branch: `fix/issue-number-description`
3. Write test that fails
4. Fix the bug
5. Verify test passes
6. Open PR
### New Components
1. Open RFC issue
2. Wait for approval
3. Fork and create branch: `feat/component-name`
4. Implement component following structure:
ComponentName/ ├── ComponentName.tsx ├── ComponentName.test.tsx ├── ComponentName.stories.tsx ├── types.ts └── index.ts
5. Add documentation
6. Open PR
### Component Requirements
- [ ] TypeScript strict mode
- [ ] Exported types
- [ ] Unit tests (>80% coverage)
- [ ] Storybook stories
- [ ] Accessibility tested
- [ ] Documentation
- [ ] Follows API conventions
## Review Process
1. **Automated checks** - CI must pass
2. **Design review** - Design team approval for visual changes
3. **Code review** - Platform team member approval
4. **Accessibility review** - For new interactive components
## Versioning
We follow semver:
- **Patch** (1.0.x): Bug fixes, no API changes
- **Minor** (1.x.0): New features, backwards compatible
- **Major** (x.0.0): Breaking changes
Breaking changes require:
- RFC approval
- Migration guide
- Deprecation notice in previous minor version
- Codemod if possible
Deprecation Policy
// packages/components/Button/Button.tsx
import { useEffect } from 'react';
interface ButtonProps {
variant?: 'solid' | 'outline' | 'ghost';
colorScheme?: 'primary' | 'neutral' | 'danger';
/**
* @deprecated Use `variant="ghost"` instead.
* Will be removed in v3.0.0.
*/
isGhost?: boolean;
/**
* @deprecated Use `colorScheme="danger"` instead.
* Will be removed in v3.0.0.
*/
isDanger?: boolean;
}
export function Button({
variant,
colorScheme,
isGhost,
isDanger,
...props
}: ButtonProps) {
// Deprecation warnings (development only)
useEffect(() => {
if (process.env.NODE_ENV === 'development') {
if (isGhost !== undefined) {
console.warn(
'[DesignSystem] Button: `isGhost` is deprecated. ' +
'Use `variant="ghost"` instead. ' +
'This prop will be removed in v3.0.0.'
);
}
if (isDanger !== undefined) {
console.warn(
'[DesignSystem] Button: `isDanger` is deprecated. ' +
'Use `colorScheme="danger"` instead. ' +
'This prop will be removed in v3.0.0.'
);
}
}
}, [isGhost, isDanger]);
// Support deprecated props during transition
const resolvedVariant = isGhost ? 'ghost' : variant;
const resolvedColorScheme = isDanger ? 'danger' : colorScheme;
return (
<button
className={buttonStyles({
variant: resolvedVariant,
colorScheme: resolvedColorScheme,
})}
{...props}
/>
);
}
// ESLint rule to flag deprecated props
// packages/eslint-plugin-company/rules/no-deprecated-props.ts
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: 'Disallow deprecated design system props',
},
fixable: 'code',
},
create(context) {
const deprecatedProps = {
Button: {
isGhost: { replacement: 'variant="ghost"', removeIn: '3.0.0' },
isDanger: { replacement: 'colorScheme="danger"', removeIn: '3.0.0' },
},
};
return {
JSXAttribute(node) {
// Check if using deprecated prop
// Provide autofix to new API
},
};
},
};
Codemods for Migration
// packages/codemods/transforms/button-v3.ts
import { Transform } from 'jscodeshift';
const transform: Transform = (file, api) => {
const j = api.jscodeshift;
const root = j(file.source);
// Find all Button components
root
.findJSXElements('Button')
.forEach((path) => {
const attributes = path.node.openingElement.attributes;
// Transform isGhost to variant="ghost"
const isGhostAttr = attributes?.find(
(attr) =>
attr.type === 'JSXAttribute' &&
attr.name.name === 'isGhost'
);
if (isGhostAttr) {
// Remove isGhost
const index = attributes!.indexOf(isGhostAttr);
attributes!.splice(index, 1);
// Add variant="ghost"
attributes!.push(
j.jsxAttribute(
j.jsxIdentifier('variant'),
j.stringLiteral('ghost')
)
);
}
// Transform isDanger to colorScheme="danger"
const isDangerAttr = attributes?.find(
(attr) =>
attr.type === 'JSXAttribute' &&
attr.name.name === 'isDanger'
);
if (isDangerAttr) {
const index = attributes!.indexOf(isDangerAttr);
attributes!.splice(index, 1);
attributes!.push(
j.jsxAttribute(
j.jsxIdentifier('colorScheme'),
j.stringLiteral('danger')
)
);
}
});
return root.toSource();
};
export default transform;
// Usage:
// npx jscodeshift -t ./transforms/button-v3.ts src/**/*.tsx
Metrics and Success
Platform Health Metrics
// packages/analytics/src/platform-metrics.ts
interface PlatformMetrics {
// Adoption
componentUsage: Record<string, number>; // How often each component is used
packageVersions: Record<string, number>; // Version distribution
adoptionRate: number; // % of teams using platform
// Quality
bugReportsPerMonth: number;
averageTimeToFix: number;
breakingChangesPerYear: number;
// Developer Experience
timeToFirstComponent: number; // Onboarding metric
documentationSatisfaction: number; // Survey score
slackQuestionsPerWeek: number; // Support burden
// Performance
bundleSizeImpact: Record<string, number>; // Size per component
averagePageLoadImpact: number;
}
// Automated usage tracking
export function trackComponentUsage() {
// Parse all consumer codebases for import analysis
// Could also use runtime telemetry in development
}
// packages/analytics/src/dashboards/platform-dashboard.tsx
export function PlatformDashboard() {
const metrics = usePlatformMetrics();
return (
<div className="grid grid-cols-3 gap-4">
{/* Adoption */}
<Card>
<h3>Adoption</h3>
<Metric value={metrics.adoptionRate} label="Teams Using Platform" />
<Trend data={metrics.adoptionTrend} />
</Card>
{/* Quality */}
<Card>
<h3>Quality</h3>
<Metric value={metrics.bugReportsPerMonth} label="Bugs/Month" />
<Metric value={metrics.averageTimeToFix} label="Avg Fix Time" />
</Card>
{/* DX */}
<Card>
<h3>Developer Experience</h3>
<Metric value={metrics.timeToFirstComponent} label="Time to First Component" />
<Metric value={metrics.documentationSatisfaction} label="Doc Satisfaction" />
</Card>
{/* Component Usage */}
<Card className="col-span-3">
<h3>Component Usage</h3>
<ComponentUsageChart data={metrics.componentUsage} />
</Card>
</div>
);
}
Support Model
┌─────────────────────────────────────────────────────────────────────┐
│ SUPPORT TIERS │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ TIER 1: SELF-SERVICE │
│ ════════════════════ │
│ • Documentation site │
│ • Storybook examples │
│ • FAQ │
│ • Searchable Slack history │
│ │
│ TIER 2: COMMUNITY │
│ ══════════════════ │
│ • #design-system Slack channel │
│ • GitHub discussions │
│ • Office hours (weekly 30min) │
│ • Response SLA: 1 business day │
│ │
│ TIER 3: DIRECT SUPPORT │
│ ═══════════════════════ │
│ • Bug reports (GitHub issues) │
│ • Feature requests (RFC process) │
│ • Pairing sessions (scheduled) │
│ • Response SLA: same business day for bugs │
│ │
│ TIER 4: EMBEDDED │
│ ═════════════════ │
│ • Platform engineer embeds with product team │
│ • For major migrations or new product launches │
│ • Time-boxed (1-2 weeks) │
│ • Requires director approval │
│ │
└─────────────────────────────────────────────────────────────────────┘
Anti-Patterns to Avoid
1. The Ivory Tower
❌ Platform team builds what they think is cool
→ Product teams don't adopt it
→ Platform team blames "resistance to change"
→ Platform becomes irrelevant
✅ Platform team talks to users constantly
→ Regular feedback sessions
→ Roadmap driven by consumer needs
→ Quick wins build trust
2. The Bottleneck
❌ Everything goes through platform team
→ PRs wait weeks for review
→ Product teams blocked on platform
→ Teams fork and build their own
→ Platform loses control
✅ Clear contribution path
→ Review SLA (< 2 business days)
→ Community reviewers from product teams
→ Self-service for common patterns
3. The Kitchen Sink
❌ Platform tries to solve every problem
→ Massive API surface
→ Maintenance burden explodes
→ Breaking changes constantly
→ Nobody knows what's stable
✅ Small, focused scope
→ Do one thing well
→ Say "no" to scope creep
→ Document what's NOT included
→ Let consumers solve their own problems
4. The Perfectionist
❌ Won't release until perfect
→ Takes 6 months for v1
→ By then, teams built alternatives
→ Adoption is near zero
→ Platform was dead on arrival
✅ Ship early, iterate fast
→ MVP in weeks
→ Unstable API warnings
→ Frequent releases
→ Perfect is the enemy of shipped
Production Checklist
Design System
- Token pipeline (design → code)
- Component library (accessible, tested)
- Storybook for development
- Figma integration (sync or kit)
Developer Experience
- CLI for scaffolding
- Code generators
- IDE extensions (snippets, autocomplete)
- Documentation site
Infrastructure
- Versioning strategy (semver)
- Release automation
- Changelog generation
- Breaking change detection
Governance
- Contribution guidelines
- RFC process for changes
- Deprecation policy
- Codemod support
Support
- Slack channel
- Office hours
- Response SLA
- Metrics dashboard
Summary
Becoming a platform team is a fundamental shift in how you work. Your product is developer productivity. Your users are engineers. Your success is measured not by features shipped, but by how much faster other teams ship because of you.
Key principles:
- Treat DX as UX - Engineers are users; their experience matters
- Stable APIs over clever features - Breaking changes destroy trust
- Documentation is product - Undocumented features don't exist
- Say no more than yes - Scope discipline prevents platform sprawl
- Measure adoption - If teams aren't using it, it's not working
The best platform teams are invisible. When everything just works—when engineers reach for your tools without thinking—that's success.
What did you think?