← Back to Blog

Theme Designer: Building a Visual MUI Theme Editor with Real-Time Preview

The Challenge & Context

In modern web development, design systems have become the cornerstone of scalable applications. However, traditional approaches to theme management often fall short when dealing with dynamic requirements, especially in white-label applications where multiple brands need distinct visual identities.

Limitations of Static Theme Packages

Most theme libraries available on npm provide static, pre-defined themes that require manual configuration and lack the flexibility needed for enterprise environments. When you need to support multiple brands or allow non-technical users to customize themes, these solutions become bottlenecks rather than enablers.

White-Label Application Requirements

The core challenge was building a system that could:

Why Existing Solutions Weren’t Sufficient

Existing theme management solutions typically:

Technical Architecture Deep Dive

State Management Strategy: Zustand Implementation

The project leverages Zustand for state management, chosen for its simplicity, TypeScript support, and minimal boilerplate compared to Redux. The theme store implements a comprehensive state management pattern with persistence.

// src/domains/themes/store/themes.store.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { themesInitialState } from "./constants";
import type { ThemesStore, ThemeResponse } from "./types";
import { THEMES_SERVICE } from "../services/themes";
import { AxiosError } from "axios";

export const useThemesStore = create<ThemesStore>()(
  persist(
    (set, get) => ({
      ...themesInitialState,

      fetchThemes: async () => {
        set({ isLoading: true, error: null });

        try {
          const response = await THEMES_SERVICE.getThemes();
          const { data, ...pagination } = response;
          
          set({
            themes: data,
            pagination: pagination as Omit<ThemeResponse, 'data'>,
            isLoading: false,
            error: null,
          });
        } catch (error) {
          set({
            isLoading: false,
            error: error instanceof AxiosError 
              ? error.response?.data.message || "Failed to fetch themes"
              : "Failed to fetch themes",
          });
        }
      },

      createTheme: async (themeData) => {
        set({ isLoading: true, error: null });

        try {
          const newTheme = await THEMES_SERVICE.createTheme(themeData);
          const currentThemes = get().themes;
          
          set({
            themes: [...(currentThemes || []), newTheme],
            isLoading: false,
            error: null,
          });
        } catch (error) {
          set({
            isLoading: false,
            error: error instanceof AxiosError 
              ? error.response?.data.message || "Failed to create theme"
              : "Failed to create theme",
          });
        }
      },

      // Additional CRUD operations...
    }),
    {
      name: "themes-store",
      partialize: (state) => ({
        themes: state.themes,
        selectedTheme: state.selectedTheme,
        pagination: state.pagination,
      }),
    }
  )
);

The store implements a clean separation of concerns with:

Validation Layer: Zod Schemas

While the current implementation focuses on theme management, the project includes Zod validation patterns for authentication forms, demonstrating the validation strategy:

// src/domains/auth/schemas/login.schema.ts
import { z } from 'zod';

export const loginSchema = z.object({
  email: z.email(),
  password: z.string()
    .min(6, { message: 'Password must be at least 6 characters long' })
    .regex(/[A-Z]/, { message: 'Password must contain at least one uppercase letter' })
    .regex(/[0-9]/, { message: 'Password must contain at least one number' }),
});

// src/domains/auth/schemas/register.schema.ts
export const registerSchema = z.object({
  firstName: z.string().min(1, { message: 'First name is required' }),
  lastName: z.string().min(1, { message: 'Last name is required' }),
  email: z.email({ message: 'Invalid email address' }),
  password: z.string()
    .min(6, { message: 'Password must be at least 6 characters long' })
    .regex(/[A-Z]/, { message: 'Password must contain at least one uppercase letter' })
    .regex(/[0-9]/, { message: 'Password must contain at least one number' }),
  confirmPassword: z.string().min(6, { message: 'Confirm password must be at least 6 characters long' }),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ["confirmPassword"],
});

Form Management: React Hook Forms Integration

The project uses React Hook Forms with Zod resolvers for form validation, providing a clean and performant form management solution:

// src/domains/auth/providers/LoginProvider.tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { FormProvider, useForm } from "react-hook-form";
import { Login } from "../pages";
import { loginSchema, type LoginSchema } from "../schemas/login.schema";

const LoginProvider = () => {
  const methods = useForm<LoginSchema>({
    mode: "all",
    resolver: zodResolver(loginSchema),
    defaultValues: {
      email: "",
      password: "",
    },
  });

  return (
    <FormProvider {...methods}>
      <Login />
    </FormProvider>
  );
};

Real-time Preview: MUI ThemeProvider Integration

The core innovation lies in the real-time theme preview system, which dynamically creates MUI themes and applies them instantly:

// src/shared/components/ThemeProvider.tsx
import type { Theme } from '@domains/themes/store/types';
import { CssBaseline } from '@mui/material';
import { createTheme, ThemeProvider as MuiThemeProvider } from '@mui/material/styles';
import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { DemoComponents } from './DemoComponents';

interface ThemeProviderProps {
  selectedTheme?: Theme | null;
  children?: ReactNode;
}

export const ThemeProvider = ({ selectedTheme, children }: ThemeProviderProps) => {
  const muiTheme = useMemo(() => {
    if (!selectedTheme?.themeConfig) {
      return createTheme();
    }

    return createTheme({
      palette: {
        mode: selectedTheme.themeConfig.palette.mode as 'light' | 'dark',
        primary: {
          main: selectedTheme.themeConfig.palette.primary.main,
        },
        secondary: {
          main: selectedTheme.themeConfig.palette.secondary.main,
        },
      },
    });
  }, [selectedTheme]);

  return (
    <MuiThemeProvider theme={muiTheme}>
      <CssBaseline />
      {children || <DemoComponents />}
    </MuiThemeProvider>
  );
};

The DemoComponents provide a comprehensive preview of all theme elements:

// src/shared/components/DemoComponents.tsx
export const DemoComponents = () => {
  return (
    <Box sx={{ p: 3, display: 'flex', flexDirection: 'column', gap: 3 }}>
      <Typography variant="h4" gutterBottom>
        Theme Preview
      </Typography>

      <Paper sx={{ p: 3 }}>
        <Typography variant="h6" gutterBottom>
          Colors & Typography
        </Typography>
        <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
          <Typography variant="h1">Heading 1</Typography>
          <Typography variant="h2">Heading 2</Typography>
          <Typography variant="h3">Heading 3</Typography>
          <Typography variant="body1">
            Body text with primary and secondary colors applied from the selected theme.
          </Typography>
          <Typography variant="body2" color="text.secondary">
            Secondary text showing the theme's text hierarchy.
          </Typography>
        </Box>
      </Paper>

      {/* Additional component previews for buttons, forms, cards, etc. */}
    </Box>
  );
};

API Integration: Axios Setup with Proper Error Handling

The project implements a robust HTTP client with interceptors for authentication and error handling:

// src/shared/services/http/client.ts
import type { AxiosInstance } from "axios";
import axios from "axios";
import { requestInterceptor, responseInterceptor } from "./interceptors";
import type { HttpClient, HttpClientConfig } from "./types";

class AxiosHttpClient implements HttpClient {
  private instance: AxiosInstance;

  constructor(baseURL?: string) {
    this.instance = axios.create({
      baseURL:
        baseURL ||
        import.meta.env.VITE_API_BASE_URL ||
        "http://localhost:3000/api",
      timeout: 10000,
      headers: {
        "Content-Type": "application/json",
      },
    });

    this.setupInterceptors();
  }

  private setupInterceptors(): void {
    // Request interceptor
    if (requestInterceptor.onFulfilled || requestInterceptor.onRejected) {
      this.instance.interceptors.request.use(
        requestInterceptor.onFulfilled,
        requestInterceptor.onRejected
      );
    }

    // Response interceptor
    if (responseInterceptor.onFulfilled || responseInterceptor.onRejected) {
      this.instance.interceptors.response.use(
        responseInterceptor.onFulfilled,
        responseInterceptor.onRejected
      );
    }
  }

  // CRUD methods with proper typing
  async get<T = unknown>(url: string, config?: HttpClientConfig): Promise<T> {
    const response = await this.instance.get<T>(url, config);
    return response.data;
  }

  async post<T = unknown>(url: string, data?: unknown, config?: HttpClientConfig): Promise<T> {
    const response = await this.instance.post<T>(url, data, config);
    return response.data;
  }

  // Additional utility methods
  setAuthToken(token: string): void {
    this.instance.defaults.headers.common["Authorization"] = `Bearer ${token}`;
    localStorage.setItem("auth_token", token);
  }

  clearAuthToken(): void {
    delete this.instance.defaults.headers.common["Authorization"];
    localStorage.removeItem("auth_token");
  }
}

export const httpClient = new AxiosHttpClient();
export default httpClient;

Implementation Highlights

Theme Structure and Type Definitions

The theme system uses a well-defined type structure that ensures consistency across the application:

// src/domains/themes/store/types/themes.type.ts
export interface Theme {
  id: string;
  name: string;
  description: string | null;
  themeConfig: ThemeConfig;
  previewImage: string | null;
  tags: string[] | null;
  isActive: boolean;
  createdById: string;
  updatedById: string;
  createdBy: CreatedBy;
  updatedBy: CreatedBy;
  createdAt: string;
  updatedAt: string;
}

interface ThemeConfig {
  palette: Palette;
}

interface Palette {
  mode: 'light' | 'dark';
  primary: {
    main: string;
  };
  secondary: {
    main: string;
  };
}

export interface ThemesState {
  pagination?: Omit<ThemeResponse, 'data'>;
  themes?: Theme[];
  selectedTheme?: Theme | null;
  isLoading: boolean;
  error: string | null;
}

export interface ThemesActions {
  fetchThemes: () => Promise<void>;
  createTheme: (theme: Omit<Theme, 'id' | 'createdAt' | 'updatedAt' | 'userId'>) => Promise<void>;
  updateTheme: (id: string, theme: Partial<Theme>) => Promise<void>;
  deleteTheme: (id: string) => Promise<void>;
  setSelectedTheme: (theme: Theme | null) => void;
  setPagination: (pagination: Omit<ThemeResponse, 'data'>) => void;
  setLoading: (loading: boolean) => void;
  setError: (error: string | null) => void;
  clearError: () => void;
}

export type ThemesStore = ThemesState & ThemesActions;

Theme Creation Wizard

The theme creation process uses a step-by-step wizard approach for better user experience:

// src/domains/themes/pages/ThemeCreate.tsx
const steps = [
  { label: 'Basic Info', icon: InfoIcon },
  { label: 'Colors', icon: ColorLensIcon },
  { label: 'Typography', icon: TextFieldsIcon },
  { label: 'Preview', icon: CheckCircleIcon },
];

const ThemeCreate = () => {
  const { createTheme } = useThemesStore();
  const navigate = useNavigate();
  const [activeStep, setActiveStep] = useState(0);
  const [previewDevice, setPreviewDevice] = useState<'desktop' | 'tablet' | 'mobile'>('desktop');
  const [isCreating, setIsCreating] = useState(false);
  const [newTheme, setNewTheme] = useState({
    name: '',
    description: '',
    themeConfig: {
      palette: {
        mode: 'light' as 'light' | 'dark',
        primary: {
          main: '#1976d2',
        },
        secondary: {
          main: '#dc004e',
        },
      },
    },
  });

  const handleColorChange = (colorType: 'primary' | 'secondary', value: string) => {
    setNewTheme(prev => ({
      ...prev,
      themeConfig: {
        ...prev.themeConfig,
        palette: {
          ...prev.themeConfig.palette,
          [colorType]: {
            ...prev.themeConfig.palette[colorType],
            main: value,
          },
        },
      },
    }));
  };

  const handleModeChange = (mode: 'light' | 'dark') => {
    setNewTheme(prev => ({
      ...prev,
      themeConfig: {
        ...prev.themeConfig,
        palette: {
          ...prev.themeConfig.palette,
          mode,
        },
      },
    }));
  };

  // Step validation and navigation logic
  const isStepValid = (step: number) => {
    switch (step) {
      case 0:
        return newTheme.name.trim().length > 0;
      case 1:
        return true; // Colors always valid
      case 2:
        return true; // Typography always valid
      case 3:
        return true; // Preview always valid
      default:
        return false;
    }
  };

  const handleCreateTheme = async () => {
    if (!isStepValid(activeStep)) return;
    
    setIsCreating(true);
    try {
      const themeToCreate = {
        ...newTheme,
        id: '',
        previewImage: null,
        tags: null,
        isActive: false,
        createdById: '',
        updatedById: '',
        createdBy: {} as CreatedBy,
        updatedBy: {} as CreatedBy,
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString(),
      };
      await createTheme(themeToCreate);
      
      navigate(ROUTES.THEMES);
    } catch (error) {
      console.error('Error creating theme:', error);
    } finally {
      setIsCreating(false);
    }
  };

  // Render step content based on active step
  const getStepContent = (step: number) => {
    switch (step) {
      case 0:
        return (
          <Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
            <Box sx={{ textAlign: 'center', mb: 3 }}>
              <InfoIcon sx={{ fontSize: 48, color: '#1E90FF', mb: 2 }} />
              <Typography variant="h5" gutterBottom sx={{ fontWeight: 600 }}>
                Basic Information
              </Typography>
              <Typography variant="body2" color="text.secondary">
                Let's start with the basics. Give your theme a name and description.
              </Typography>
            </Box>
            
            <TextField
              label="Theme Name"
              value={newTheme.name}
              onChange={(e) => setNewTheme(prev => ({ ...prev, name: e.target.value }))}
              fullWidth
              variant="outlined"
              required
              error={!newTheme.name.trim() && activeStep > 0}
              helperText={!newTheme.name.trim() && activeStep > 0 ? 'Theme name is required' : ''}
              sx={{
                '& .MuiOutlinedInput-root': {
                  backgroundColor: '#F8F9FA',
                  borderRadius: '8px',
                },
              }}
            />
            
            <TextField
              label="Description"
              value={newTheme.description}
              onChange={(e) => setNewTheme(prev => ({ ...prev, description: e.target.value }))}
              multiline
              rows={4}
              fullWidth
              variant="outlined"
              helperText="Optional: Describe your theme's purpose and style"
              sx={{
                '& .MuiOutlinedInput-root': {
                  backgroundColor: '#F8F9FA',
                  borderRadius: '8px',
                },
              }}
            />
          </Box>
        );
      // Additional step implementations...
    }
  };

  return (
    <Container maxWidth="lg" sx={{ py: 4 }}>
      {/* Stepper and content rendering */}
    </Container>
  );
};

Theme Editing Interface

The theme editing interface provides a comprehensive tabbed interface for modifying existing themes:

// src/domains/themes/pages/ThemeEdit.tsx
const ThemeEdit = () => {
  const { id } = useParams();
  const navigate = useNavigate();
  const { themes, updateTheme, isLoading } = useThemesStore();
  const [editingTheme, setEditingTheme] = useState<Theme | null>(null);
  const [tabValue, setTabValue] = useState(0);
  const [previewDevice, setPreviewDevice] = useState<'desktop' | 'tablet' | 'mobile'>('desktop');
  const [isSaving, setIsSaving] = useState(false);
  const [showSaveSuccess, setShowSaveSuccess] = useState(false);
  const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);

  const selectedTheme = useMemo(() => {
    return themes?.find(theme => theme.id === id);
  }, [themes, id]);

  useEffect(() => {
    if (selectedTheme) {
      setEditingTheme(selectedTheme);
    }
  }, [selectedTheme]);

  useEffect(() => {
    if (selectedTheme && editingTheme) {
      const hasChanges = JSON.stringify(selectedTheme) !== JSON.stringify(editingTheme);
      setHasUnsavedChanges(hasChanges);
    }
  }, [selectedTheme, editingTheme]);

  const handleColorChange = (colorType: "primary" | "secondary", value: string) => {
    setEditingTheme((prev) => {
      if (!prev) return null;
      return {
        ...prev,
        themeConfig: {
          ...prev.themeConfig,
          palette: {
            ...prev.themeConfig.palette,
            [colorType]: {
              ...prev.themeConfig.palette[colorType],
              main: value,
            },
          },
        },
      };
    });
  };

  const handleModeChange = (mode: "light" | "dark") => {
    setEditingTheme((prev) => {
      if (!prev) return null;
      return {
        ...prev,
        themeConfig: {
          ...prev.themeConfig,
          palette: {
            ...prev.themeConfig.palette,
            mode,
          },
        },
      };
    });
  };

  const handleSave = async () => {
    setIsSaving(true);
    try {
      await updateTheme(editingTheme.id, editingTheme);
      setShowSaveSuccess(true);
      setHasUnsavedChanges(false);
    } catch (error) {
      console.error('Error saving theme:', error);
    } finally {
      setIsSaving(false);
    }
  };

  return (
    <Container maxWidth="lg" sx={{ py: 4 }}>
      {/* Tabbed interface with real-time preview */}
      <Grid container spacing={3}>
        <Grid item xs={12} md={8}>
          {/* Theme editing controls */}
        </Grid>
        <Grid item xs={12} md={4}>
          {/* Real-time preview */}
          <ThemeProvider selectedTheme={editingTheme}>
            <DemoComponents />
          </ThemeProvider>
        </Grid>
      </Grid>
    </Container>
  );
};

Technical Challenges & Solutions

Managing Complex Nested Theme Objects

Challenge: Theme objects contain deeply nested structures that need to be updated atomically while maintaining immutability.

Solution: Implemented a structured approach using spread operators and proper state management:

const handleColorChange = (colorType: 'primary' | 'secondary', value: string) => {
  setNewTheme(prev => ({
    ...prev,
    themeConfig: {
      ...prev.themeConfig,
      palette: {
        ...prev.themeConfig.palette,
        [colorType]: {
          ...prev.themeConfig.palette[colorType],
          main: value,
        },
      },
    },
  }));
};

Performance Optimization for Real-time Updates

Challenge: Real-time theme updates could cause performance issues with frequent re-renders.

Solution: Implemented useMemo for theme creation and optimized component structure:

const muiTheme = useMemo(() => {
  if (!selectedTheme?.themeConfig) {
    return createTheme();
  }

  return createTheme({
    palette: {
      mode: selectedTheme.themeConfig.palette.mode as 'light' | 'dark',
      primary: {
        main: selectedTheme.themeConfig.palette.primary.main,
      },
      secondary: {
        main: selectedTheme.themeConfig.palette.secondary.main,
      },
    },
  });
}, [selectedTheme]);

Type Safety Across the Entire Theme System

Challenge: Ensuring type safety across theme creation, editing, and preview systems.

Solution: Comprehensive TypeScript interfaces and strict typing:

export interface Theme {
  id: string;
  name: string;
  description: string | null;
  themeConfig: ThemeConfig;
  previewImage: string | null;
  tags: string[] | null;
  isActive: boolean;
  createdById: string;
  updatedById: string;
  createdBy: CreatedBy;
  updatedBy: CreatedBy;
  createdAt: string;
  updatedAt: string;
}

export type ThemesStore = ThemesState & ThemesActions;

Integration with Backend Persistence

Challenge: Seamless integration with backend API while maintaining offline capabilities.

Solution: Zustand persistence middleware with proper error handling:

export const useThemesStore = create<ThemesStore>()(
  persist(
    (set, get) => ({
      // Store implementation
    }),
    {
      name: "themes-store",
      partialize: (state) => ({
        themes: state.themes,
        selectedTheme: state.selectedTheme,
        pagination: state.pagination,
      }),
    }
  )
);

Development Process & Decisions

Why Vite

Decision: Chose Vite for its superior development experience and build performance.

Benefits:

// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@app': path.resolve(__dirname, 'src/app'),
      '@domains': path.resolve(__dirname, 'src/domains'),
      '@shared': path.resolve(__dirname, 'src/shared'),
    },
  },
})

State Management Decision: Zustand vs Redux/Context

Decision: Chose Zustand for its simplicity and TypeScript integration.

Rationale:

Form Library Selection Rationale

Decision: React Hook Forms with Zod validation.

Benefits:

Validation Strategy Choices

Decision: Zod for runtime validation with TypeScript integration.

Advantages:

Results & Impact

Performance Metrics

Developer Experience Improvements

Scalability Achievements

Future Enhancement Possibilities

Lessons Learned

What Worked Well

  1. Domain-Driven Architecture: Clear separation of concerns made the codebase maintainable and scalable
  2. TypeScript Integration: Comprehensive typing prevented runtime errors and improved developer experience
  3. Zustand State Management: Simple yet powerful state management with excellent persistence
  4. Real-time Preview: Instant visual feedback significantly improved user experience
  5. Component Composition: Reusable components with proper prop interfaces

What Would Be Done Differently

  1. Theme Schema Validation: Implement Zod schemas for theme validation from the start
  2. Performance Monitoring: Add performance metrics and monitoring earlier
  3. Testing Strategy: Implement comprehensive unit and integration tests
  4. Documentation: More detailed API documentation and usage examples
  5. Accessibility: Enhanced accessibility features and ARIA support

Key Takeaways for Similar Projects

  1. Start with TypeScript: Type safety from the beginning prevents many issues
  2. Choose the Right Tools: Vite, Zustand, and React Hook Forms provided excellent developer experience
  3. Plan for Scalability: Domain-driven architecture supports future growth
  4. Focus on User Experience: Real-time preview and intuitive interfaces are crucial
  5. Error Handling: Comprehensive error handling improves reliability
  6. Performance First: Optimize for performance from the start, not as an afterthought

Conclusion

The Theme Designer project demonstrates how modern frontend technologies can be combined to create powerful, user-friendly applications that solve real-world problems. By leveraging React, MUI, Zustand, and TypeScript, we built a comprehensive theme management system that goes beyond static npm packages to provide dynamic, real-time theme creation and preview capabilities.

The project showcases several key architectural principles:

This case study illustrates how thoughtful architecture decisions, combined with modern tooling, can result in applications that are not only functional but also maintainable, scalable, and enjoyable to use. The Theme Designer serves as a foundation for building dynamic design systems that can adapt to the evolving needs of modern web applications.


This project demonstrates the power of combining modern frontend technologies to solve complex design system challenges. The codebase is available on GitHub for further exploration and contribution.