From Figma to Production: My End-to-End Process for Building Real Websites
A detailed walkthrough of my complete workflow—from receiving Figma designs to deploying production-ready websites that actually work.
I've built enough websites to know that the gap between "looks good in Figma" and "works perfectly in production" is where most projects stumble. Here's the exact process I follow to bridge that gap consistently.
The Problem Most Developers Face
You get a beautiful Figma design. You're excited. You start coding. Then reality hits:
- Font sizes don't match between design and code
- Spacing feels off everywhere
- Components look different on mobile
- Interactions weren't specified
- Performance tanks with all those images
- Design doesn't account for loading states or errors
Sound familiar? This workflow solves all of that.
My Complete Workflow Overview
Here's the bird's eye view of my process:
Figma Audit → Design System Setup → Component Library →
Page Building → Integration → Testing → Optimization → Deployment
Each step builds on the previous one. Skip a step, and you'll regret it later.
Step One: The Figma Audit
Before writing a single line of code, I spend time analyzing the Figma file. This saves hours of refactoring.
What I Look For
Design tokens:
- Typography scale (is it consistent?)
- Color palette (how many variations exist?)
- Spacing system (is there a pattern?)
- Border radius values
- Shadow styles
Component patterns:
- Which components appear multiple times?
- What are the variants (primary button, secondary button)?
- Are there inconsistencies between similar components?
Responsive behavior:
- Are there mobile designs?
- How do layouts adapt?
- What breaks at medium screen sizes?
My Audit Checklist
// I literally use this checklist
const figmaAudit = {
typography: {
headings: [], // h1, h2, h3, etc.
body: [], // paragraph sizes
captions: [], // small text
},
colors: {
primary: [],
neutral: [],
semantic: [], // success, error, warning
},
spacing: {
scale: [], // 4, 8, 12, 16, 24, 32, etc.
gaps: [],
padding: [],
},
components: {
buttons: [],
cards: [],
forms: [],
navigation: [],
},
breakpoints: {
mobile: 0,
tablet: 768,
desktop: 1024,
wide: 1440,
},
};Red Flags to Address
If I spot these issues, I talk to the designer before coding:
- Inconsistent spacing (some gaps are 14px, others 16px)
- Too many font sizes (more than seven or eight)
- Colors without clear purpose
- No hover or focus states
- Missing error or loading states
- No mobile designs
Step Two: Design System Setup
Now I translate Figma's design tokens into code. I use CSS variables for maximum flexibility.
Setting Up Tokens
/* styles/tokens.css */
:root {
/* Colors */
--color-primary-50: #f0f9ff;
--color-primary-100: #e0f2fe;
--color-primary-500: #0ea5e9;
--color-primary-600: #0284c7;
--color-primary-900: #0c4a6e;
/* Typography */
--font-sans: "Inter", -apple-system, sans-serif;
--font-mono: "Fira Code", monospace;
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 1.875rem; /* 30px */
--text-4xl: 2.25rem; /* 36px */
/* Spacing */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
/* Border radius */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-full: 9999px;
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
}Tailwind Config (if using Tailwind)
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
primary: {
50: "var(--color-primary-50)",
100: "var(--color-primary-100)",
500: "var(--color-primary-500)",
600: "var(--color-primary-600)",
900: "var(--color-primary-900)",
},
},
fontSize: {
xs: "var(--text-xs)",
sm: "var(--text-sm)",
base: "var(--text-base)",
lg: "var(--text-lg)",
xl: "var(--text-xl)",
"2xl": "var(--text-2xl)",
"3xl": "var(--text-3xl)",
"4xl": "var(--text-4xl)",
},
spacing: {
1: "var(--space-1)",
2: "var(--space-2)",
3: "var(--space-3)",
4: "var(--space-4)",
6: "var(--space-6)",
8: "var(--space-8)",
12: "var(--space-12)",
16: "var(--space-16)",
},
},
},
};Why CSS Variables Matter
- Theming: Dark mode is just swapping variables
- Consistency: One source of truth
- Flexibility: Can override at component level
- Inspector: See actual values in DevTools
Step Three: Building the Component Library
I start with the smallest reusable pieces and work my way up.
Component Building Order
Level one - Primitives:
Button → Input → Label → Checkbox → Radio → Select
Level two - Compositions:
Card → Modal → Dropdown → Form Field → Navigation Item
Level three - Features:
Hero Section → Contact Form → Product Card → Testimonial
Example: Building a Button Component
// components/ui/button.tsx
import { type VariantProps, cva } from "class-variance-authority";
import { forwardRef, type ButtonHTMLAttributes } from "react";
const buttonVariants = cva(
// Base styles
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
primary: "bg-primary-600 hover:bg-primary-700 text-white",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200",
outline: "border border-gray-300 bg-transparent hover:bg-gray-50",
ghost: "hover:bg-gray-100",
danger: "bg-red-600 text-white hover:bg-red-700",
},
size: {
sm: "h-9 px-3 text-sm",
md: "h-10 px-4 text-base",
lg: "h-12 px-6 text-lg",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
}
);
interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, isLoading, children, ...props }, ref) => {
return (
<button
className={buttonVariants({ variant, size, className })}
ref={ref}
disabled={isLoading}
{...props}
>
{isLoading ? (
<>
<Spinner className="mr-2" />
Loading...
</>
) : (
children
)}
</button>
);
}
);Component Documentation
I use Storybook or a simple component showcase:
// components/showcase/button-examples.tsx
export function ButtonShowcase() {
return (
<div className="space-y-8">
<Section title="Variants">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="danger">Danger</Button>
</Section>
<Section title="Sizes">
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</Section>
<Section title="States">
<Button>Normal</Button>
<Button isLoading>Loading</Button>
<Button disabled>Disabled</Button>
</Section>
</div>
);
}Step Four: Page Building Strategy
With components ready, I build pages section by section.
My Section-First Approach
Instead of building entire pages, I build sections:
// features/landing/components/hero-section.tsx
export function HeroSection() {
return (
<section className="from-primary-50 relative overflow-hidden bg-gradient-to-b to-white py-20">
<Container>
<div className="grid gap-12 lg:grid-cols-2 lg:gap-8">
<div className="flex flex-col justify-center">
<h1 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-5xl xl:text-6xl">
Build better products faster
</h1>
<p className="mt-6 text-lg text-gray-600">
The all-in-one platform for modern teams...
</p>
<div className="mt-8 flex gap-4">
<Button size="lg">Get Started</Button>
<Button variant="outline" size="lg">
Learn More
</Button>
</div>
</div>
<div className="relative">
<Image
src="/hero-image.png"
alt="Product screenshot"
width={600}
height={400}
className="rounded-lg shadow-2xl"
/>
</div>
</div>
</Container>
</section>
);
}Benefits of Section Components
- Reusable: Same hero on multiple pages
- Testable: Test sections in isolation
- Maintainable: Easy to find and update
- Composable: Mix and match to create pages
Page Composition
// app/page.tsx
import { HeroSection } from "@/features/landing/components/hero-section";
import { FeaturesSection } from "@/features/landing/components/features-section";
import { TestimonialsSection } from "@/features/landing/components/testimonials-section";
import { CTASection } from "@/features/landing/components/cta-section";
export default function HomePage() {
return (
<>
<HeroSection />
<FeaturesSection />
<TestimonialsSection />
<CTASection />
</>
);
}Clean, readable, and maintainable.
Step Five: Responsive Implementation
I use a mobile-first approach with Tailwind's breakpoint system.
My Responsive Strategy
// Mobile first, then enhance
<div className="// Mobile: 1 column // Tablet: 2 columns // Desktop: 3 columns // Mobile: 16px gap // Tablet: 24px gap // Desktop: 32px gap grid grid-cols-1 gap-4 md:grid-cols-2 md:gap-6 lg:grid-cols-3 lg:gap-8">
{items.map((item) => (
<Card key={item.id} {...item} />
))}
</div>Testing Responsiveness
I test at these breakpoints:
- Mobile: 375px (iPhone SE)
- Tablet: 768px (iPad)
- Laptop: 1024px
- Desktop: 1440px
- Wide: 1920px
Common Responsive Patterns
Stack to Grid:
<div className="flex flex-col gap-6 lg:flex-row lg:gap-8">
<aside className="lg:w-64">Sidebar</aside>
<main className="flex-1">Content</main>
</div>Hide/Show Elements:
<nav>
{/* Mobile menu button */}
<button className="lg:hidden">Menu</button>
{/* Desktop navigation */}
<ul className="hidden lg:flex lg:gap-6">
<li>Home</li>
<li>About</li>
</ul>
</nav>Step Six: Images and Assets Optimization
Images can make or break performance. Here's my optimization workflow.
Image Processing Pipeline
For Next.js projects:
import Image from "next/image";
// Automatically optimized
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority // Above the fold
quality={85}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>;For static images:
I use this script to optimize all images:
# package.json
"scripts": {
"optimize:images": "npx @squoosh/cli --webp auto public/images/*.{jpg,png}"
}Image Best Practices
- Hero images: WebP format, max 1920px wide
- Product images: Multiple sizes for srcset
- Icons: SVG when possible, or icon fonts
- Thumbnails: Generate at build time
- Always: Provide width and height to prevent layout shift
Loading States for Images
"use client";
import { useState } from "react";
import Image from "next/image";
export function OptimizedImage({ src, alt, ...props }) {
const [isLoading, setIsLoading] = useState(true);
return (
<div className="relative">
{isLoading && (
<div className="absolute inset-0 animate-pulse bg-gray-200" />
)}
<Image
src={src}
alt={alt}
onLoadingComplete={() => setIsLoading(false)}
{...props}
/>
</div>
);
}Step Seven: Interactions and Animations
This is where designs come alive. I follow the principle: enhance, don't distract.
Animation Guidelines
What to animate:
- Button hovers and clicks
- Page transitions
- Loading states
- Toast notifications
- Modal entrances
- Dropdown menus
What NOT to animate:
- Body text appearing
- Every single element on scroll
- Anything longer than 500ms
- Critical UI elements users need immediately
My Animation Stack
// Using Framer Motion for complex animations
import { motion } from "framer-motion";
export function Card({ children }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4 }}
className="rounded-lg border p-6"
>
{children}
</motion.div>
);
}/* Using CSS for simple transitions */
.button {
transition: all 200ms ease;
}
.button:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}Respecting User Preferences
/* Disable animations for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}Step Eight: Forms and Data Handling
Forms are where theory meets reality. Here's how I handle them properly.
Form Validation Strategy
I use React Hook Form with Zod for bulletproof validation:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const contactSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
message: z.string().min(10, "Message must be at least 10 characters"),
});
type ContactFormData = z.infer<typeof contactSchema>;
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
});
const onSubmit = async (data: ContactFormData) => {
try {
await fetch("/api/contact", {
method: "POST",
body: JSON.stringify(data),
});
toast.success("Message sent successfully!");
} catch (error) {
toast.error("Failed to send message");
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div>
<Label htmlFor="name">Name</Label>
<Input id="name" {...register("name")} error={errors.name?.message} />
</div>
<div>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
{...register("email")}
error={errors.email?.message}
/>
</div>
<div>
<Label htmlFor="message">Message</Label>
<Textarea
id="message"
{...register("message")}
error={errors.message?.message}
/>
</div>
<Button type="submit" isLoading={isSubmitting}>
Send Message
</Button>
</form>
);
}Form UX Best Practices
- Inline validation: Show errors as users type
- Disable on submit: Prevent double submissions
- Clear success states: Show confirmation
- Preserve data: Don't clear form on error
- Accessible errors: Link labels to inputs
- Loading states: Show what's happening
Step Nine: Testing Before Launch
I test across three dimensions: visual, functional, and performance.
Visual Testing Checklist
- All pages render correctly on mobile, tablet, desktop
- Images load and display properly
- Fonts load (no FOUT or FOIT)
- Colors match Figma
- Spacing is consistent
- Hover states work
- Focus states are visible
- Dark mode works (if applicable)
Functional Testing
// Example: Testing a contact form
describe('ContactForm', () => {
it('validates required fields', async () => {
render(<ContactForm />);
const submitButton = screen.getByRole('button', { name: /send/i });
await userEvent.click(submitButton);
expect(screen.getByText(/name is required/i)).toBeInTheDocument();
});
it('submits form with valid data', async () => {
render(<ContactForm />);
await userEvent.type(screen.getByLabelText(/name/i), 'John Doe');
await userEvent.type(screen.getByLabelText(/email/i), 'john@example.com');
await userEvent.type(screen.getByLabelText(/message/i), 'Hello there!');
await userEvent.click(screen.getByRole('button', { name: /send/i }));
await waitFor(() => {
expect(screen.getByText(/message sent/i)).toBeInTheDocument();
});
});
});Performance Testing
I use Lighthouse and aim for these scores:
- Performance: above 90
- Accessibility: above 95
- Best Practices: 100
- SEO: above 95
Common performance issues I fix:
// Bad: Loading all images eagerly
<img src="/large-image.jpg" />
// Good: Lazy loading with Next.js Image
<Image
src="/large-image.jpg"
alt="Description"
width={800}
height={600}
loading="lazy"
/>
// Bad: Importing entire library
import _ from 'lodash';
// Good: Import only what you need
import debounce from 'lodash/debounce';
// Bad: No code splitting
import HugeComponent from './HugeComponent';
// Good: Dynamic import
const HugeComponent = dynamic(() => import('./HugeComponent'));Step Ten: Deployment and Monitoring
The last step is getting it live and making sure it stays healthy.
Pre-Deployment Checklist
- Environment variables configured
- Database migrations run
- Error tracking set up (Sentry)
- Analytics installed (Plausible/Google Analytics)
- Performance monitoring active (Vercel Analytics)
- SEO meta tags added
- Sitemap generated
- robots.txt configured
- Favicons and PWA icons ready
My Deployment Process
# Run final checks
npm run lint
npm run type-check
npm run build
# Test production build locally
npm run start
# Deploy to preview (Vercel)
git push origin feature-branch
# Review preview URL
# Deploy to production
git checkout main
git merge feature-branch
git push origin mainPost-Deployment Monitoring
I track these metrics:
- Core Web Vitals: LCP, FID, CLS
- Error rate: JavaScript errors
- API response times: Database queries
- User engagement: Time on page, bounce rate
Setting Up Error Boundaries
// components/error-boundary.tsx
"use client";
import { Component, type ReactNode } from "react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: any) {
console.error("Error caught by boundary:", error, errorInfo);
// Send to error tracking service
}
render() {
if (this.state.hasError) {
return (
this.props.fallback || (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-bold">Something went wrong</h2>
<button
onClick={() => this.setState({ hasError: false })}
className="mt-4"
>
Try again
</button>
</div>
</div>
)
);
}
return this.props.children;
}
}Tools I Use in This Workflow
Design:
- Figma for design review
- Figma tokens plugin for extracting design tokens
Development:
- Next.js for the framework
- Tailwind CSS for styling
- TypeScript for type safety
- Shadcn UI for base components
- Framer Motion for animations
Quality:
- ESLint and Prettier for code quality
- React Hook Form and Zod for forms
- Vitest for unit tests
- Playwright for E2E tests
Deployment:
- Vercel for hosting
- Sentry for error tracking
- Plausible for privacy-friendly analytics
Common Pitfalls and How I Avoid Them
Pitfall: Starting to code before understanding the design
- Solution: Always do the Figma audit first
Pitfall: Pixel-perfect obsession
- Solution: Focus on visual harmony over exact pixel matching
Pitfall: Not considering edge cases
- Solution: Think about empty states, errors, and loading from day one
Pitfall: Ignoring performance until the end
- Solution: Use Next.js Image, code splitting, and lazy loading from the start
Pitfall: Building everything custom
- Solution: Use proven libraries (Shadcn, Radix) for common components
Time Breakdown
For a typical landing page project, here's how I spend my time:
- Figma audit and planning: 10 percent
- Design system setup: 15 percent
- Component development: 25 percent
- Page building: 25 percent
- Integration and interactions: 15 percent
- Testing and optimization: 10 percent
The upfront planning pays off in faster, cleaner development later.
Conclusion
Going from Figma to production isn't just about writing code that matches a design. It's about building something that:
- Looks right across all devices
- Feels right with smooth interactions
- Works right with proper error handling
- Performs right with optimized assets
- Scales right with maintainable code
This workflow has helped me ship dozens of projects on time and with fewer bugs. The secret isn't working faster—it's working in the right order.
Start with understanding, build with intention, and ship with confidence.
Want to see this process in action? Check out my portfolio where every project followed this exact workflow.