How we built a scalable multi-brand platform that serves multiple projects from one codebase
Introduction
Consider a scenario where you are building a talent marketplace platform. You start with one brand, let's call it TechCorp. It's successful, and now you want to launch HealthApp - a similar platform but for healthcare jobs. Then EduJobs for education, and FinanceHub for finance.
The challenge: Do you create separate codebases for each brand? Or can you build one codebase that serves all of them?
In this post, we'll explore how we implemented a shared-first architecture that allows us to run multiple brands from a single codebase, reducing maintenance overhead while maintaining brand-specific customizations.
The Problem: Code Duplication Hell
The Traditional Approach (What We Avoided)
The naive approach would be to create separate codebases:
project/
├── techcorp-frontend/ (500 files)
├── healthapp-frontend/ (500 files - mostly duplicated!)
├── edujobs-frontend/ (500 files - mostly duplicated!)
└── financehub-frontend/ (500 files - mostly duplicated!)
Problems with this approach:
- ❌ 4x maintenance: Fix a bug? Fix it 4 times!
- ❌ Code drift: Features get out of sync across brands
- ❌ Slow development: New features take 4x longer
- ❌ Testing nightmare: Test everything 4 times
- ❌ Resource waste: Same code, different locations
Real-World Example
Let's say you have an EmployerDashboard component:
TechCorp version:
// techcorp-frontend/src/pages/EmployerDashboard.tsx
export function EmployerDashboard() {
return (
<div>
<h1>TechCorp Employer Dashboard</h1>
<JobList />
<StripePayment /> {/* TechCorp uses Stripe */}
</div>
);
}
HealthApp version:
// healthapp-frontend/src/pages/EmployerDashboard.tsx
export function EmployerDashboard() {
return (
<div>
<h1>HealthApp Employer Dashboard</h1>
<JobList />
{/* HealthApp doesn't use payments */}
</div>
);
}
Notice: 90% of the code is identical! Only the payment flow differs.
The Solution: Shared-First Architecture
Core Principle
"Start with shared, override only when necessary"
Instead of duplicating code, we:
- Create shared code that all brands use by default
- Create brand-specific overrides only when there's a real difference
- Use automatic resolution to pick the right code at runtime
Architecture Overview
src/
├── pages/
│ ├── shared/ # ✅ DEFAULT - Used by all brands
│ │ └── employer/
│ │ └── EmployerDashboard.tsx
│ │
│ └── brands/ # 🎨 OVERRIDES - Only when needed
│ ├── techcorp/
│ │ └── employer/
│ │ └── EmployerDashboard.tsx # Only if different
│ └── healthapp/
│ └── employer/
│ └── EmployerDashboard.tsx # Only if different
│
├── components/
│ ├── shared/ # Shared components
│ └── brands/ # Brand-specific overrides
│
├── services/
│ ├── shared/ # Shared API services
│ └── brands/ # Brand-specific services
│
└── config/
├── brands/ # Brand configurations
├── features/ # Feature flags
└── locales/ # Translations per brand
How It Works
When the application needs a component, it follows this resolution:
1. Check: Does the current brand have its own version?
✅ YES → Use brand-specific version
❌ NO → Continue to step 2
2. Check: Does shared version exist?
✅ YES → Use shared version
❌ NO → Error (component missing)
Example:
-
TechCorp needs
EmployerDashboard- Checks:
pages/brands/techcorp/employer/EmployerDashboard.tsx❌ Not found - Checks:
pages/shared/employer/EmployerDashboard.tsx✅ Found - Result: Uses shared version
- Checks:
-
HealthApp needs
EmployerDashboard- Checks:
pages/brands/healthapp/employer/EmployerDashboard.tsx✅ Found! - Result: Uses HealthApp-specific version
- Checks:
Implementation: Step by Step
Step 1: Brand Configuration
First, we define each brand's configuration:
// src/config/brands/brands/techcorp.ts
export const techcorpConfig: BrandConfig = {
id: 'techcorp',
name: 'TechCorp',
region: 'US',
locale: 'en-US',
api: {
baseUrl: 'https://api.techcorp.com/api/v1',
},
features: [
'jobs.applications',
'payments.stripe', // ✅ Stripe enabled
'payments.coupons',
'jobs.places-autocomplete', // ✅ Google Maps enabled
],
ui: {
logo: '/techcorp-logo.png',
primaryColor: '#3b82f6',
theme: 'light',
},
};
// src/config/brands/brands/healthapp.ts
export const healthappConfig: BrandConfig = {
id: 'healthapp',
name: 'HealthApp',
region: 'US',
locale: 'en-US',
api: {
baseUrl: 'https://api.healthapp.com/api/v1',
},
features: [
'jobs.applications',
// 'payments.stripe', // ❌ Stripe disabled
// 'jobs.places-autocomplete', // ❌ Google Maps disabled
],
ui: {
logo: '/healthapp-logo.png',
primaryColor: '#10b981',
theme: 'light',
},
};
Step 2: Feature Flags System
We use feature flags to enable/disable features per brand:
// src/config/features/features.ts
export const FEATURES: Record<string, FeatureDefinition> = {
'payments.stripe': {
id: 'payments.stripe',
name: 'Stripe Payments',
description: 'Enable Stripe payment processing',
category: 'payments',
defaultEnabled: true,
},
'jobs.places-autocomplete': {
id: 'jobs.places-autocomplete',
name: 'Google Places Autocomplete',
description: 'Enable Google Places autocomplete for job locations',
category: 'jobs',
defaultEnabled: true,
},
};
Step 3: Shared Component with Feature Flags
Now we create a shared component that adapts based on feature flags:
// src/pages/shared/employer/EmployerDashboard.tsx
import { useFeature } from '@/hooks/useFeature';
import { useTranslation } from '@/hooks/useTranslation';
export function EmployerDashboard() {
const hasStripe = useFeature('payments.stripe');
const { t } = useTranslation();
const handlePublishJob = async (jobId: string) => {
if (!hasStripe) {
// HealthApp: Direct publish without payment
await publishJobDirectly(jobId);
return;
}
// TechCorp: Show payment flow
setShowPaymentDialog(true);
};
return (
<div>
<h1>{t('employer.dashboard.title')}</h1>
<JobList />
{hasStripe && (
<StripePaymentDialog
open={showPaymentDialog}
onComplete={handlePaymentComplete}
/>
)}
</div>
);
}
Result:
-
TechCorp (
hasStripe = true): Shows payment dialog -
HealthApp (
hasStripe = false): Publishes directly
Step 4: Dynamic Component Resolution
For components that need brand-specific versions, we use dynamic resolution:
// src/utils/brandResolver.ts
export async function resolveBrandPage(
path: string
): Promise<{ default: ComponentType }> {
const brandId = getCurrentBrandId();
// Try brand-specific first
try {
const brandModule = await import(
/* @vite-ignore */ `@/pages/brands/${brandId}/${path}.tsx`
);
return { default: brandModule.default };
} catch (error) {
// Fallback to shared
const sharedModule = await import(
/* @vite-ignore */ `@/pages/shared/${path}.tsx`
);
return { default: sharedModule.default };
}
}
Usage in routing:
// src/App.tsx
import { lazy } from 'react';
import { resolveBrandPage } from '@/utils/brandResolver';
const EmployerDashboard = lazy(() =>
resolveBrandPage('employer/EmployerDashboard')
);
function App() {
return (
<Routes>
<Route
path="/employer/dashboard"
element={<EmployerDashboard />}
/>
</Routes>
);
}
Step 5: Service Resolution
Services (API calls) also use brand-specific resolution:
// src/utils/brandServiceResolver.ts
export async function resolveServiceModule(
servicePath: string
): Promise<any> {
const brandId = getCurrentBrandId();
try {
// Try brand-specific service
return await import(
/* @vite-ignore */ `@/services/brands/${brandId}/${servicePath}.ts`
);
} catch (error) {
// Fallback to shared service
return await import(
/* @vite-ignore */ `@/services/shared/${servicePath}.ts`
);
}
}
Usage:
// In a component
const fetchJobs = async () => {
// Automatically resolves to:
// - services/brands/techcorp/employer/jobBoardApi.ts (if exists)
// - services/shared/employer/jobBoardApi.ts (fallback)
const jobApi = await resolveServiceModule('employer/jobBoardApi');
const jobs = await jobApi.getJobPosts();
return jobs;
};
Real-World Example: Location Input
Let's see a complete example of how we handle different UI requirements.
The Requirement
- TechCorp: Needs Google Places Autocomplete for location input
- HealthApp: Simple text input is sufficient
Implementation
1. Define the feature:
// src/config/features/features.ts
'jobs.places-autocomplete': {
id: 'jobs.places-autocomplete',
name: 'Google Places Autocomplete',
description: 'Enable Google Places autocomplete for job locations',
category: 'jobs',
defaultEnabled: true,
},
2. Enable for TechCorp:
// src/config/brands/brands/techcorp.ts
features: [
'jobs.places-autocomplete', // ✅ Enabled
],
3. Create shared component with conditional rendering:
// src/pages/shared/employer/CreateJobAd.tsx
import { useFeature } from '@/hooks/useFeature';
import PlacesAutocomplete from 'react-places-autocomplete';
export function CreateJobAd() {
const hasPlacesAutocomplete = useFeature('jobs.places-autocomplete');
const [isGoogleMapsLoaded, setIsGoogleMapsLoaded] = useState(false);
// Only load Google Maps if feature is enabled
useEffect(() => {
if (!hasPlacesAutocomplete) {
setIsGoogleMapsLoaded(true); // Not needed
return;
}
loadGoogleMapsScript()
.then(() => setIsGoogleMapsLoaded(true))
.catch(console.error);
}, [hasPlacesAutocomplete]);
return (
<form>
<label>Job Location *</label>
{hasPlacesAutocomplete ? (
// TechCorp: PlacesAutocomplete
!isGoogleMapsLoaded ? (
<div>Loading location services...</div>
) : (
<PlacesAutocomplete
value={location}
onChange={setLocation}
onSelect={handleLocationSelect}
>
{({ getInputProps, suggestions, getSuggestionItemProps }) => (
<div>
<input {...getInputProps({ placeholder: 'Select city' })} />
{suggestions.map((suggestion) => (
<div {...getSuggestionItemProps(suggestion)}>
{suggestion.description}
</div>
))}
</div>
)}
</PlacesAutocomplete>
)
) : (
// HealthApp: Simple input
<input
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="Enter location"
/>
)}
</form>
);
}
Result:
- TechCorp: Gets Google Places Autocomplete with suggestions
- HealthApp: Gets simple text input
- Same component: No code duplication!
Translation System
Each brand can have its own translations:
// src/config/locales/translations/techcorp/en-US.ts
export const techcorpTranslations = {
common: {
welcome: 'Welcome to TechCorp',
submit: 'Submit',
},
employer: {
dashboard: {
title: 'TechCorp Employer Dashboard',
},
},
};
// src/config/locales/translations/healthapp/en-US.ts
export const healthappTranslations = {
common: {
welcome: 'Welcome to HealthApp',
submit: 'Submit',
},
employer: {
dashboard: {
title: 'HealthApp Employer Dashboard',
},
},
};
Usage:
import { useTranslation } from '@/hooks/useTranslation';
function Header() {
const { t } = useTranslation();
return <h1>{t('common.welcome')}</h1>;
// TechCorp: "Welcome to TechCorp"
// HealthApp: "Welcome to HealthApp"
}
Type System: Shared-First Pattern
Types also follow the shared-first pattern:
// src/types/shared/employer/jobs.ts
export interface Job {
id: string;
title: string;
description: string;
status: 'draft' | 'published' | 'closed';
}
// src/types/brands/techcorp/employer/jobs.ts
// Only if TechCorp needs additional types
export interface TechCorpJob extends Job {
techStack: string[]; // TechCorp-specific field
}
Usage:
// Always import from shared first
import { Job } from '@/types/shared/employer/jobs';
// Brand-specific types override automatically if they exist
Benefits: Why This Approach Works
1. Reduced Maintenance
Before (separate codebases):
- Bug fix: Fix in 4 places
- New feature: Implement 4 times
- Testing: Test 4 times
After (shared-first):
- Bug fix: Fix once, all brands benefit
- New feature: Implement once, all brands get it
- Testing: Test once (with brand variations)
2. Consistency
All brands share the same core functionality, ensuring:
- Consistent user experience
- Same bug fixes across brands
- Unified feature set
3. Faster Development
- New features roll out to all brands simultaneously
- Less code to write and maintain
- Easier onboarding for new developers
4. Flexibility
- Brand-specific customizations when needed
- Feature flags for gradual rollouts
- Easy to add new brands
5. Cost Efficiency
- Single codebase to maintain
- Shared infrastructure
- Reduced development time
Best Practices
1. Start with Shared
✅ DO:
// Create shared component first
// src/components/shared/employer/JobCard.tsx
export function JobCard({ job }) {
return <div>{job.title}</div>;
}
❌ DON'T:
// Don't create brand-specific "just in case"
// Wait until you actually need it
2. Use Feature Flags for Conditional Behavior
✅ DO:
const hasFeature = useFeature('feature.id');
{hasFeature ? <FeatureComponent /> : <DefaultComponent />}
❌ DON'T:
// Don't create brand-specific component for simple conditionals
if (brandId === 'techcorp') {
return <TechCorpComponent />;
}
3. Create Brand-Specific Only When Necessary
✅ Create brand-specific when:
- Different API endpoints
- Completely different UI structure
- Different business logic that can't be conditional
- Different validation rules
❌ Don't create brand-specific for:
- Text differences (use translations)
- Styling differences (use CSS/theme)
- Simple conditional behavior (use feature flags)
4. Keep Brand-Specific Code Minimal
// ✅ GOOD: Minimal brand-specific override
// src/pages/brands/healthapp/employer/EmployerDashboard.tsx
import SharedDashboard from '@/pages/shared/employer/EmployerDashboard';
export function EmployerDashboard() {
// Only override what's different
return (
<SharedDashboard
showPayment={false} // HealthApp-specific prop
/>
);
}
// ❌ BAD: Duplicating entire component
export function EmployerDashboard() {
// Copying 500 lines of shared code...
}
Migration Strategy
If you're migrating from separate codebases:
Step 1: Identify Shared Code
# Compare files across brands
diff techcorp/src/pages/Dashboard.tsx healthapp/src/pages/Dashboard.tsx
Step 2: Move to Shared
# Move shared code
mv techcorp/src/pages/Dashboard.tsx shared/src/pages/Dashboard.tsx
Step 3: Update Imports
// Before
import Dashboard from '@/pages/brands/techcorp/Dashboard';
// After
import Dashboard from '@/pages/shared/Dashboard';
Step 4: Handle Differences
// Use feature flags or brand-specific overrides
const hasFeature = useFeature('feature.id');
Step 5: Test
- Test with each brand
- Verify brand-specific overrides work
- Ensure shared code works for all brands
Performance Considerations
Code Splitting
Each brand only loads what it needs:
// Lazy loading with brand resolution
const EmployerDashboard = lazy(() =>
resolveBrandPage('employer/EmployerDashboard')
);
Bundle Size
- Shared code: Loaded once
- Brand-specific code: Only loaded when needed
- Feature flags: No runtime overhead
Caching
- Shared components cached across brands
- Brand-specific components cached per brand
- Translations cached per brand
Common Patterns
Pattern 1: Conditional UI
const hasFeature = useFeature('feature.id');
return (
<div>
{hasFeature && <FeatureComponent />}
<DefaultComponent />
</div>
);
Pattern 2: Conditional Logic
const handleAction = async () => {
if (hasFeature) {
return await complexFlow();
}
return await simpleFlow();
};
Pattern 3: Brand-Specific Override
// Shared component
// src/components/shared/employer/JobCard.tsx
export function JobCard({ job }) {
return <div>{job.title}</div>;
}
// Brand-specific override
// src/components/brands/healthapp/employer/JobCard.tsx
export function JobCard({ job }) {
return (
<div className="healthapp-theme">
<h2>{job.title}</h2>
<p>{job.description}</p>
</div>
);
}
Testing Strategy
Unit Tests
describe('EmployerDashboard', () => {
it('works with Stripe enabled', () => {
setBrand('techcorp');
const { getByText } = render(<EmployerDashboard />);
expect(getByText('Payment')).toBeInTheDocument();
});
it('works without Stripe', () => {
setBrand('healthapp');
const { queryByText } = render(<EmployerDashboard />);
expect(queryByText('Payment')).not.toBeInTheDocument();
});
});
Integration Tests
describe('Brand Resolution', () => {
it('resolves brand-specific component', async () => {
setBrand('healthapp');
const component = await resolveBrandPage('employer/Dashboard');
expect(component).toBeDefined();
});
it('falls back to shared component', async () => {
setBrand('techcorp');
const component = await resolveBrandPage('employer/Dashboard');
expect(component).toBeDefined();
});
});
Challenges and Solutions
Challenge 1: Vite Dynamic Imports
Problem: Vite doesn't support dynamic template literals in imports.
Solution: Use static import maps or /* @vite-ignore */:
const brandId = getCurrentBrandId();
const module = await import(
/* @vite-ignore */ `@/pages/brands/${brandId}/Dashboard.tsx`
);
Challenge 2: TypeScript Type Resolution
Problem: TypeScript can't resolve dynamic imports.
Solution: Use shared types as base, brand-specific types extend:
// Always import from shared
import { Job } from '@/types/shared/employer/jobs';
// Brand-specific types override if they exist
Challenge 3: Testing Multiple Brands
Problem: Need to test with different brand configurations.
Solution: Use test utilities:
function setBrandForTesting(brandId: string) {
process.env.VITE_BRAND_ID = brandId;
// Re-initialize brand config
}
Conclusion
Building multiple projects with a single codebase is not only possible but highly beneficial when done right. The shared-first architecture pattern allows us to:
- ✅ Maintain one codebase instead of multiple
- ✅ Customize per brand when needed
- ✅ Deploy faster with shared features
- ✅ Reduce bugs with single source of truth
- ✅ Scale easily by adding new brands
Key Takeaways
- Start with shared code - Default to shared implementations
- Override only when necessary - Create brand-specific files sparingly
- Use feature flags - For conditional behavior instead of duplication
- Use translations - For text differences, not code duplication
- Test thoroughly - With all brands to ensure compatibility
Next Steps
If you're considering this approach:
- Start small - Migrate one component at a time
- Use feature flags - For gradual migration
- Document everything - Keep track of brand-specific differences
- Test continuously - Ensure all brands work correctly
"Simple and basic mood" Happy coding!
Have questions or feedback? Feel free to reach out!





Top comments (0)