Dynamic SCSS Loading for Multiple Domains with Shared Ionic Codebase

Managing multiple branded websites or applications that share the same codebase is a common challenge for developers. When you need to maintain different visual identities across multiple domains while keeping the functionality consistent, dynamic SCSS loading is an elegant solution. This article walks through implementing this approach in an Ionic application.

The Challenge

Imagine you’re managing a SaaS platform that serves different clients through their own domains, or perhaps you have a white-label solution that needs to adapt to different branding requirements. The core functionality remains the same, but each domain needs its own:

  • Color scheme
  • Typography
  • Component styling
  • Layout variations
  • Brand-specific assets

Rather than maintaining separate codebases or using complex build configurations, we can dynamically load domain-specific styles based on the current URL.

Solution Architecture

Our approach consists of four main components:

  1. A Theme Manager Service that detects the current domain and loads the appropriate styles
  2. A Structured SCSS Organization with shared and domain-specific variables and styles
  3. Angular Build Configuration that generates separate CSS bundles
  4. Application Integration to properly initialize the theme system

Let’s implement each component step by step.

1. Theme Manager Service

The Theme Manager Service detects the current domain and applies the appropriate theme:

typescriptCopy// File: src/theme/theme-manager.service.ts
import { Injectable } from '@angular/core';
import { Platform } from '@ionic/angular';

@Injectable({
  providedIn: 'root'
})
export class ThemeManagerService {
  private domainThemes = {
    'domain1.com': 'domain1',
    'domain2.com': 'domain2'
    // Add more domains as needed
  };

  private currentTheme: string;

  constructor(private platform: Platform) {
    this.initTheme();
  }

  private initTheme() {
    // Get current hostname
    const hostname = window.location.hostname;
    
    // Determine theme based on hostname
    this.currentTheme = this.domainThemes[hostname] || 'default';
    
    // Apply theme
    this.applyTheme(this.currentTheme);
  }

  private applyTheme(themeName: string) {
    // Remove any existing theme class from body
    document.body.classList.forEach(className => {
      if (className.startsWith('theme-')) {
        document.body.classList.remove(className);
      }
    });
    
    // Add new theme class
    document.body.classList.add(`theme-${themeName}`);
    
    // Load the theme stylesheet dynamically
    this.loadStylesheet(themeName);
  }

  private loadStylesheet(themeName: string) {
    // Create link element
    const linkElement = document.createElement('link');
    linkElement.rel = 'stylesheet';
    linkElement.href = `assets/themes/${themeName}.css`;
    linkElement.id = `theme-${themeName}-stylesheet`;
    
    // Remove any previous theme stylesheet
    const existingLink = document.getElementById('theme-stylesheet');
    if (existingLink) {
      existingLink.remove();
    }
    
    // Add new stylesheet to head
    linkElement.id = 'theme-stylesheet';
    document.head.appendChild(linkElement);
  }

  // Public method to switch theme manually if needed
  public switchTheme(themeName: string) {
    if (this.domainThemes[themeName] || themeName === 'default') {
      this.applyTheme(themeName);
    }
  }

  // Get current theme name
  public getCurrentTheme(): string {
    return this.currentTheme;
  }
}

This service:

  • Maps domain names to theme names
  • Detects the current domain on initialization
  • Applies a domain-specific CSS class to the document body
  • Dynamically loads the appropriate stylesheet
  • Provides methods for manually switching themes (useful for testing)

2. SCSS Structure

Organizing your SCSS files properly is crucial for maintainability. Here’s a structure that separates shared variables from domain-specific styling:

scssCopy// File: src/theme/variables.scss (Shared variables)
:root {
  // Common variables used across all themes
  // These would be overridden by domain-specific themes
  --ion-font-family: 'Roboto', sans-serif;
  --ion-padding: 16px;
  --ion-margin: 16px;
  
  // Default theme variables
  --ion-color-primary: #3880ff;
  --ion-color-primary-rgb: 56, 128, 255;
  --ion-color-primary-contrast: #ffffff;
  --ion-color-primary-contrast-rgb: 255, 255, 255;
  --ion-color-primary-shade: #3171e0;
  --ion-color-primary-tint: #4c8dff;
  
  // Other ionic default colors...
}

// File: src/theme/domain1/variables.scss
.theme-domain1 {
  --ion-color-primary: #ff4961;
  --ion-color-primary-rgb: 255, 73, 97;
  --ion-color-primary-contrast: #ffffff;
  --ion-color-primary-contrast-rgb: 255, 255, 255;
  --ion-color-primary-shade: #e04055;
  --ion-color-primary-tint: #ff5b71;
  
  // Define other colors for domain1
  
  // Domain-specific font or other variables
  --ion-font-family: 'Open Sans', sans-serif;
  
  // Custom domain-specific variables
  --domain-header-background: #f7f7f7;
  --domain-logo-size: 100px;
}

// File: src/theme/domain2/variables.scss
.theme-domain2 {
  --ion-color-primary: #2dd36f;
  --ion-color-primary-rgb: 45, 211, 111;
  --ion-color-primary-contrast: #ffffff;
  --ion-color-primary-contrast-rgb: 255, 255, 255;
  --ion-color-primary-shade: #28ba62;
  --ion-color-primary-tint: #42d77d;
  
  // Define other colors for domain2
  
  // Domain-specific font or other variables
  --ion-font-family: 'Montserrat', sans-serif;
  
  // Custom domain-specific variables
  --domain-header-background: #eafbef;
  --domain-logo-size: 120px;
}

// File: src/theme/domain1/styles.scss
@import '../variables';
@import './variables';

// Domain1 specific styles
.theme-domain1 {
  // Header customization
  ion-header {
    background-color: var(--domain-header-background);
  }
  
  // Logo customization
  .logo {
    width: var(--domain-logo-size);
    height: auto;
  }
  
  // Other domain-specific styles
  .welcome-card {
    border-radius: 10px;
    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
  }
  
  // Override any component styles specific to this domain
}

// File: src/theme/domain2/styles.scss
@import '../variables';
@import './variables';

// Domain2 specific styles
.theme-domain2 {
  // Header customization
  ion-header {
    background-color: var(--domain-header-background);
    border-bottom: 2px solid var(--ion-color-primary);
  }
  
  // Logo customization
  .logo {
    width: var(--domain-logo-size);
    height: auto;
  }
  
  // Other domain-specific styles
  .welcome-card {
    border-radius: 15px;
    box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
  }
  
  // Override any component styles specific to this domain
}

This structure:

  • Defines global CSS variables in a shared file
  • Creates domain-specific variable overrides
  • Encapsulates domain-specific styles within their own scoped classes
  • Uses the .theme-domain1 and .theme-domain2 classes to scope styling (which match the classes our service applies to the body)

3. Angular Configuration

To compile these SCSS files into separate bundles that can be loaded dynamically, we need to update the Angular configuration:

jsonCopy// File: angular.json (modified to build theme files)
{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "projects": {
    "app": {
      "architect": {
        "build": {
          "options": {
            "outputPath": "www",
            "index": "src/index.html",
            "main": "src/main.ts",
            "polyfills": "src/polyfills.ts",
            "tsConfig": "tsconfig.app.json",
            "assets": [
              {
                "glob": "**/*",
                "input": "src/assets",
                "output": "assets"
              }
            ],
            "styles": [
              {
                "input": "src/theme/variables.scss",
                "bundleName": "global"
              },
              {
                "input": "src/theme/domain1/styles.scss",
                "bundleName": "domain1"
              },
              {
                "input": "src/theme/domain2/styles.scss",
                "bundleName": "domain2"
              },
              {
                "input": "src/global.scss",
                "bundleName": "global"
              }
            ]
            // other options...
          }
          // other configurations...
        }
      }
    }
  }
}

This configuration:

  • Creates separate CSS bundles for each domain
  • Names the bundles so they can be easily referenced
  • Ensures the global styles are always included

4. Application Integration

Finally, we need to integrate the Theme Manager Service into our application:

typescriptCopy// File: src/app/app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';
import { IonicModule, IonicRouteStrategy } from '@ionic/angular';

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';
import { ThemeManagerService } from '../theme/theme-manager.service';

@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule,
    IonicModule.forRoot(),
    AppRoutingModule
  ],
  providers: [
    { provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
    ThemeManagerService
  ],
  bootstrap: [AppComponent]
})
export class AppModule {}

// File: src/app/app.component.ts
import { Component } from '@angular/core';
import { Platform } from '@ionic/angular';
import { ThemeManagerService } from '../theme/theme-manager.service';

@Component({
  selector: 'app-root',
  templateUrl: 'app.component.html',
  styleUrls: ['app.component.scss'],
})
export class AppComponent {
  constructor(
    private platform: Platform,
    private themeManager: ThemeManagerService
  ) {
    this.initializeApp();
  }

  initializeApp() {
    this.platform.ready().then(() => {
      // Theme manager service will initialize and load appropriate theme
      console.log(`Current theme: ${this.themeManager.getCurrentTheme()}`);
    });
  }
}

And update the index.html file to prepare for dynamic stylesheet loading:

htmlCopy<!-- File: src/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <title>Ionic App</title>

  <base href="/" />

  <meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
  <meta name="format-detection" content="telephone=no" />
  <meta name="msapplication-tap-highlight" content="no" />

  <link rel="icon" type="image/png" href="assets/icon/favicon.png" />

  <!-- add to homescreen for ios -->
  <meta name="apple-mobile-web-app-capable" content="yes" />
  <meta name="apple-mobile-web-app-status-bar-style" content="black" />
  
  <!-- The global stylesheet will be loaded by default -->
  <!-- Theme-specific stylesheets will be loaded dynamically by the ThemeManagerService -->
</head>
<body>
  <app-root></app-root>
</body>
</html>

Testing and Development Workflow

Local Testing

To test different themes during development:

  1. Edit your hosts file to create domain aliases that point to localhost: Copy127.0.0.1 domain1.local 127.0.0.1 domain2.local
  2. Use the switchTheme method for quick testing: typescriptCopy// In a development component: constructor(private themeManager: ThemeManagerService) {} switchToDomain1() { this.themeManager.switchTheme('domain1'); } switchToDomain2() { this.themeManager.switchTheme('domain2'); }
  3. Create a theme switcher component for easier testing: typescriptCopy@Component({ selector: 'app-theme-switcher', template: ` <ion-segment (ionChange)="segmentChanged($event)"> <ion-segment-button value="domain1"> <ion-label>Domain 1</ion-label> </ion-segment-button> <ion-segment-button value="domain2"> <ion-label>Domain 2</ion-label> </ion-segment-button> </ion-segment> ` }) export class ThemeSwitcherComponent { constructor(private themeManager: ThemeManagerService) {} segmentChanged(ev: any) { this.themeManager.switchTheme(ev.detail.value); } }

Production Deployment

For production, you’ll need to ensure that your build and deployment pipeline:

  1. Correctly generates all theme bundles
  2. Includes these bundles in the deployment package
  3. Places them in the correct location in your assets folder
  4. Configures your web server to properly serve these assets

Advanced Techniques

Dynamic Font Loading

If your domains use different fonts, consider implementing dynamic font loading:

typescriptCopyprivate loadDomainFonts(themeName: string) {
  const fontFamilies = {
    'domain1': ['Open Sans:400,700', 'Roboto:400,500'],
    'domain2': ['Montserrat:400,700', 'Lato:400,700']
  };
  
  const fonts = fontFamilies[themeName];
  if (fonts) {
    const link = document.createElement('link');
    link.rel = 'stylesheet';
    link.href = `https://fonts.googleapis.com/css?family=${fonts.join('|')}&display=swap`;
    document.head.appendChild(link);
  }
}

Environment-specific Configuration

Create environment-specific domain mappings:

typescriptCopy// environments/environment.ts
export const environment = {
  production: false,
  domainThemeMap: {
    'domain1.local': 'domain1',
    'domain2.local': 'domain2',
    'localhost': 'domain1' // default for local development
  }
};

// environments/environment.prod.ts
export const environment = {
  production: true,
  domainThemeMap: {
    'domain1.com': 'domain1',
    'domain2.com': 'domain2'
  }
};

Then update the Theme Manager Service:

typescriptCopyimport { environment } from '../environments/environment';

@Injectable({ providedIn: 'root' })
export class ThemeManagerService {
  private domainThemes = environment.domainThemeMap;
  // rest of the service...
}

Optimizing Bundle Sizes

To reduce the size of each theme bundle, consider:

  1. Using CSS Variables extensively: Define colors and other properties as variables in your base theme, then only override what changes in domain-specific themes.
  2. Isolating domain-specific styles: Only put truly domain-specific styles in the domain bundles, keeping common styling in the global bundle.
  3. Lazy loading components: If certain components are only used in specific domains, lazy load them with Angular’s routing system.

Best Practices for Multi-domain Styling

  1. Create a design system first: Define a consistent design system with components, spacing, and typography rules that work across all domains.
  2. Use CSS variables for everything: Colors, spacing, typography, and any domain-specific value should be a CSS variable.
  3. Build components to respect theming: Make sure your components read from CSS variables rather than having hardcoded values.
  4. Keep theme-specific overrides minimal: Try to design your system so domain-specific overrides are exceptions, not the rule.
  5. Test across all domains regularly: Ensure that changes to shared components work properly across all themed variations.

Conclusion

Dynamic SCSS loading for multiple domains in an Ionic application provides a powerful way to maintain different brand identities while sharing a single codebase. This approach offers several benefits:

  • Maintainability: Core functionality changes propagate across all domains automatically
  • Performance: Each domain only loads the styles it needs
  • Scalability: Adding new domains is as simple as creating new theme files and updating a mapping
  • Development efficiency: Developers can work on a single codebase instead of managing multiple versions

By following the patterns outlined in this article, you can create a flexible, maintainable system for managing multiple visual identities across different domains in your Ionic application.