DEV Community

Cover image for Building React Multiple Projects with a Single Codebase: A Shared-First Architecture Approach
Abishek Dongol
Abishek Dongol

Posted on

Building React Multiple Projects with a Single Codebase: A Shared-First Architecture Approach

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 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!)
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notice: 90% of the code is identical! Only the payment flow differs.


The Solution: Shared-First Architecture

Shared-First Architecture Structure

Core Principle

"Start with shared, override only when necessary"

Instead of duplicating code, we:

  1. Create shared code that all brands use by default
  2. Create brand-specific overrides only when there's a real difference
  3. 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
Enter fullscreen mode Exit fullscreen mode

How It Works

When the application needs a component, it follows this resolution:

Resolution Flow Diagram

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)
Enter fullscreen mode Exit fullscreen mode

Example:

  • TechCorp needs EmployerDashboard

    • Checks: pages/brands/techcorp/employer/EmployerDashboard.tsx ❌ Not found
    • Checks: pages/shared/employer/EmployerDashboard.tsx ✅ Found
    • Result: Uses shared version
  • HealthApp needs EmployerDashboard

    • Checks: pages/brands/healthapp/employer/EmployerDashboard.tsx ✅ Found!
    • Result: Uses HealthApp-specific version

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',
  },
};
Enter fullscreen mode Exit fullscreen mode

Step 2: Feature Flags System

We use feature flags to enable/disable features per brand:

Feature Flags in Action

// 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,
  },
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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`
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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,
},
Enter fullscreen mode Exit fullscreen mode

2. Enable for TechCorp:

// src/config/brands/brands/techcorp.ts
features: [
  'jobs.places-autocomplete', // ✅ Enabled
],
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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',
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Usage:

// Always import from shared first
import { Job } from '@/types/shared/employer/jobs';

// Brand-specific types override automatically if they exist
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

❌ DON'T:

// Don't create brand-specific "just in case"
// Wait until you actually need it
Enter fullscreen mode Exit fullscreen mode

2. Use Feature Flags for Conditional Behavior

✅ DO:

const hasFeature = useFeature('feature.id');
{hasFeature ? <FeatureComponent /> : <DefaultComponent />}
Enter fullscreen mode Exit fullscreen mode

❌ DON'T:

// Don't create brand-specific component for simple conditionals
if (brandId === 'techcorp') {
  return <TechCorpComponent />;
}
Enter fullscreen mode Exit fullscreen mode

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...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Step 2: Move to Shared

# Move shared code
mv techcorp/src/pages/Dashboard.tsx shared/src/pages/Dashboard.tsx
Enter fullscreen mode Exit fullscreen mode

Step 3: Update Imports

// Before
import Dashboard from '@/pages/brands/techcorp/Dashboard';

// After
import Dashboard from '@/pages/shared/Dashboard';
Enter fullscreen mode Exit fullscreen mode

Step 4: Handle Differences

// Use feature flags or brand-specific overrides
const hasFeature = useFeature('feature.id');
Enter fullscreen mode Exit fullscreen mode

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')
);
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

Pattern 2: Conditional Logic

const handleAction = async () => {
  if (hasFeature) {
    return await complexFlow();
  }
  return await simpleFlow();
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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();
  });
});
Enter fullscreen mode Exit fullscreen mode

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();
  });
});
Enter fullscreen mode Exit fullscreen mode

Challenges and Solutions

Before vs After Comparison

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`
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

  1. Start with shared code - Default to shared implementations
  2. Override only when necessary - Create brand-specific files sparingly
  3. Use feature flags - For conditional behavior instead of duplication
  4. Use translations - For text differences, not code duplication
  5. Test thoroughly - With all brands to ensure compatibility

Next Steps

If you're considering this approach:

  1. Start small - Migrate one component at a time
  2. Use feature flags - For gradual migration
  3. Document everything - Keep track of brand-specific differences
  4. 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)