Per-Tenant Theming with eox-tenant

Hi everyone,

I have a working multi-tenant Open edX setup using eox-tenant and tutor-contrib-mfe-extensions. Now I want to apply different branding (colors, logos, header/footer) for each tenant.

My questions:

  • Is it possible to use a different Indigo theme per tenant, or what’s the recommended approach for per-tenant theming on the LMS side?
  • For the MFEs, since all tenants share the same build, how can we apply different branding per tenant at runtime? Is PARAGON_THEME_URLS the right way to go?

Would love to hear how others are handling this. Thanks!

Hi Friend!

Good questions — this comes up a lot as more people move multi-tenant deployments onto the MFE architecture. Here’s how I’d break it down, LMS side and MFE side separately, since they use completely different mechanisms.

LMS side (Indigo per tenant)

Yes, this is doable, and you don’t need a separate deploy per tenant. The combo that works well is eox-tenant + eox-theming:

  1. Make sure USE_EOX_TENANT is enabled and your themes are already available under COMPREHENSIVE_THEME_DIRS (each theme folder can be a fork/variant of Indigo, or a lightweight theme that just overrides a few SCSS variables and images on top of Indigo).
  2. With eox-theming installed, you add a THEME_OPTIONS block to each tenant’s config (via the eox-tenant admin or API):

json

{
  "THEME_OPTIONS": {
    "theme": {
      "name": "tenant-a-theme",
      "parent": "indigo",
      "grandparent": "base-theme"
    }
  }
}

eox-theming does a hierarchical template lookup (name → parent → grandparent), so you only need to override what’s actually different per tenant — logo, colors, maybe a couple of templates — and let everything else fall back to Indigo. Switching a tenant’s theme is just a config change, no rebuild/redeploy needed on the LMS side.

One gotcha to watch for: on newer Open edX releases (Teak+), _make_mako_template_dirs needs a settings argument now, so if you’re on an older eox-theming snippet from the docs, double check that against your release.

MFE side (shared build, different branding per tenant)

This is the part where people usually get stuck, and yes — PARAGON_THEME_URLS is the right mechanism, but it’s important to understand why it works and where its limits are.

Why you can’t just install two brand packages: brand packages (@edx/brand-*) and Indigo’s MFE brand are npm packages baked into the build at compile time. You physically can’t have two of them active in the same build — this has been confirmed multiple times in the forum (see the “Apply multiple different brands at the same openedx page” thread). So the “one MFE build, N tenants” requirement rules out the classic npm-alias branding approach entirely.

What actually works: CSS variables + runtime URLs. Since Paragon v23 (Teak release), Paragon and brand packages moved from SCSS variables (resolved at build time) to CSS custom properties (resolved at runtime). That means you can build one base Paragon theme, and then swap out the stylesheet that defines the variables without touching the MFE build at all. This is exactly what PARAGON_THEME_URLS is for:

json

{
  "PARAGON_THEME_URLS": {
    "core": {
      "urls": {
        "default": "https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/core.min.css"
      }
    },
    "defaults": { "light": "light" },
    "variants": {
      "light": {
        "urls": {
          "default": "https://cdn.jsdelivr.net/npm/@openedx/paragon@$paragonVersion/dist/light.min.css",
          "brandOverride": "https://your-cdn.example.com/tenant-a/light.min.css"
        }
      }
    }
  }
}

The core/variant split keeps the base Paragon CSS shared (better caching) while brandOverride layers just your tenant’s tokens on top. You compile each tenant’s token set with paragon build-scss, host the resulting dist files wherever (S3, CDN, even served statically by tutor-mfe itself), and just point brandOverride to the right file.

Making it per-tenant at runtime is the piece tutor-contrib-mfe-extensions and mfe_config_api solve together:

  • mfe_config_api lets each Open edX site/tenant serve its own MFE_CONFIG (logos, PARAGON_THEME_URLS, feature flags, etc.) based on the requesting domain — this is the actual per-tenant switch.
  • tutor-contrib-mfe-extensions is useful here mainly for routing: it lets you serve MFEs by path under each tenant’s own LMS domain (https://tenant-a.example.com/learning) instead of a single shared apps. subdomain, which is what makes “MFE reads config for its own domain” work cleanly in a multi-tenant setup.

So the practical flow is: request hits tenant-a.example.com → Caddy/your routing serves the shared MFE build → the MFE calls mfe_config_api on tenant-a.example.com → gets back tenant A’s PARAGON_THEME_URLS.variants.light.urls.brandOverride and logo URLs → applies them at runtime. No rebuild per tenant.

Things that will NOT work at runtime

Worth being upfront about scope, since I’ve seen people burn time here:

  • Structural header/footer differences (different nav items, different layout, not just colors/logo) aren’t achievable this way — frontend-component-header/footer are still npm-aliased at build time. If tenants need genuinely different header/footer structure (not just branding), you’re back to separate MFE builds per tenant, or a header/footer component that reads tenant config and conditionally renders — more engineering effort.
  • Logos and simple text (site name, some copy) usually go through MFE_CONFIG directly (e.g. LOGO_URL) rather than through Paragon tokens — check whichever plugin you’re using (tutor-contrib-branding from Aulasneo is a good reference implementation if you want something more turnkey than hand-rolling it).
  • Comprehensive theming (SCSS-based, build-time) is being phased out in favor of design tokens going forward, so it’s worth building your tenant theming pipeline around PARAGON_THEME_URLS/tokens now rather than investing further in the old SCSS override approach.

We’ve been running something close to this exact setup in production for a while now, and the above is what we’ve observed works reliably. Happy to go deeper on any part of it if useful.

Hi @Le00m_1

Thank you so much for the detailed explanation! Really appreciate it.

We’ll try the different theming approaches and get back to you if we have questions.

Thanks again!