Multi-Layer Runtime Configuration: How to Change Your Entire UI Without Rebuilding
The Problem
Hardcoded configuration is everywhere:
Need to change the primary color? Edit the code, rebuild, redeploy. Want to test two different button styles? Duplicate code or feature flags in your CI/CD. Supporting multiple clients with different branding? Maintain separate codebases or complex build configurations.
CSS variables help with colors, but what about behavior? Feature flags? Icons? Layout preferences? The configuration lives scattered across dozens of files.
This article explores runtime configuration where the entire UI: colors, fonts, layouts, icons, behavior; is driven by JSON loaded from URLs. Change a config file, refresh the page, done.
Think of it like TinaCMS for application configuration, not just content.
The Inspiration: Lightroom Presets
The idea came from an unexpected place: photo editing.
Years ago, I watched my sister (a professional photographer) process wedding photos. She'd have dozens of images but didn't adjust each manually. She used Lightroom presets.
Click. Preset applied. The whole photo transformed: color grading, contrast, brightness, everything. One preset for outdoor weddings, another for indoor ceremonies. Change the preset, change the entire look.
That pattern stuck with me. What if web UIs could work the same way? Prepare different "presets" for different clients or use cases, load them at runtime, and instantly transform the entire application?
Finding More Inspiration
The Lightroom idea was one piece. Material UI's theming system provided another.
Material UI has an interactive theme editor in their documentation. You adjust colors, fonts, spacing, and see changes instantly. IntelliJ IDEA even has a Material UI theme plugin where you select different themes and the entire palette changes immediately.
The key insight: Material UI's theming is data-driven. You pass a theme object to a provider, and all components use it. Change the object, change the entire look. No rebuilding, no find-and-replace, just configuration.
What if an entire application could work like that? Everything driven by a configuration object loaded from different sources, merged together, and changed at runtime?
The Vision: Multi-Layer Configuration
The concept was simple, execution complex:
- Everything dynamic goes in config: Colors, fonts, sizes, icons, labels, behavior flags
- Multiple layers: Library defaults, application defaults, component defaults, runtime overrides from URLs
- Automatic merging: Later layers override earlier ones, deep merging all the way down
- Zero-rebuild changes: Update the config file, refresh the page, done
What becomes possible:
- Client A wants purple branding, Client B wants orange => Two config URLs, same codebase
- Test button placement with 10% of users => Serve different config based on user segment
- Shipped a buggy feature? => Set
feature.newDashboard.enabled: falsein config, instant rollback - CEO wants to see the new design before launch => Give them a special config URL
- Rebranding the entire company => Update one JSON file, not 50 React components
Building it was the challenge.
Layer 1: The Library Default
I started with the foundation: a default configuration object baked into the library itself. This would be the fallback, if nothing else is specified, use these values.
Looking at the code now at src/providers/config/config/index.ts, it's... extensive. About 740 lines of configuration covering everything from console log levels to button styles to Material UI theme overrides.
Here's a tiny slice of what it looks like:
export const defaultConfig: WidgetConfig = {
console: {
level: {
log: true,
info: true,
error: true,
warning: true
}
},
fetch: {
credentials: 'include',
mode: 'cors',
authorization: {
source: {
type: 'cookie',
attributeName: 'Authorization'
},
destination: {
type: 'header',
attributeName: 'Authorization'
}
}
},
i18n: {
lng: 'EN',
fallbackLng: 'EN'
},
theme: {
palette: {
primary: {main: 'rgb(65, 0, 153)'},
secondary: {main: 'rgb(84, 86, 90)'}
}
}
// ... hundreds more lines
}
This config includes everything: fonts, colors, spacing, border styles, component-specific settings, internationalization config, authentication settings, you name it. If it can vary between deployments or clients, it went in The Config.
Layer 2 & 3: Application and Embed Defaults
But a single default wasn't enough. Multiple levels of defaults were needed:
- Library Default: The base config shipped with the library
- Application Default: Overrides specific to the entire application
- Component Default: Overrides specific to a particular component/widget
- Runtime Config: Overrides loaded from a URL at runtime
Why so many layers? Different parts of the system need different levels of control.
The library default says "here's what a button looks like in general."
The application default says "but in this application, use a different color scheme."
The component default says "and this particular component has a custom title."
The runtime config says "and for this specific client, override everything with their branding."
The implementation is actually pretty elegant. Check out src/providers/config/config.store.ts:
export const configStore = {
id: 'configStoreId',
implementation: () => {
return {
manualConfig: {}, // Application + Embed defaults
loadedConfig: {}, // Runtime config from URL
defaultConfig: {}, // Library default
get() {
// The magic: merge all layers
return resolveConfig(
withDefault(
merge(this.manualConfig, this.loadedConfig),
this.defaultConfig
)
)
}
}
}
}
The get() method merges everything: manual config first, then loaded config (overriding manual), then defaults fill in any gaps. It's using Lodash's deep merge, which means nested objects merge recursively. Perfect.
The Merge Problem
Of course, deep merging configuration objects turned out to be trickier than I expected.
Consider this scenario: The default config has a button with 10 properties. The application config overrides 3 of them. The runtime config overrides 2 more. What should the final config look like?
Answer: All 10 properties, with 5 overridden and 5 still at defaults.
Simple, right? Except when arrays are involved. Or when you want to remove a property, not just override it. Or when merge order matters for some properties but not others.
I spent way too long debugging cases where configs weren't merging the way I expected. The solution was to be very explicit about merge behavior and document it clearly:
// Shallow merge for top-level
const merge = (...configs) => lodash.merge({}, ...configs)
// Deep defaults for filling gaps
const withDefault = (config, defaultConfig) =>
lodash.defaultsDeep({}, config, defaultConfig)
The order matters: merge first (runtime overrides everything), then defaultsDeep (defaults fill gaps). Get the order wrong, and your runtime config doesn't work. Trust me, I learned this the hard way.
Loading Config From URLs
The fourth layer, runtime config from a URL, was the game changer. This is what enables zero-rebuild rebranding.
The implementation in src/providers/config/useConfig.ts is straightforward:
export const useConfig = (props) => {
const {configUrl = '', config: manualConfig = {}, defaultConfig = {}} = props
const configStore = useConfigStore()
const {get} = useFetch(configStore.get())
useEffect(() => {
if (!isEmpty(configUrl)) {
get(configUrl)
.then(loadedConfig => {
configStore.setLoadedConfig(loadedConfig)
})
.catch(error => {/* handle error */
})
}
}, [configUrl])
return {reload: loadConfig}
}
Pass a URL, the system fetches it, merges it with the other layers, done. The config can be a static JSON file, a dynamic API endpoint, whatever. As long as it returns a valid config object, it gets merged in.
For components using the embed pattern (see Article 0), the config URL goes right in the HTML:
<embed-widget config-url="${RUNTIME_CONFIG_URL}"></embed-widget>
The host application passes in an environment variable, the component fetches it, and client-specific configuration loaded at runtime.
Variable Substitution: Config Referencing Config
Here's where things got really interesting (and maybe a bit crazy). I wanted config values to reference other config values.
Why? Because I didn't want to repeat myself. If I define a primary color once, I want to reuse it everywhere. But JSON doesn't have variables.
Enter json-variables, a library that lets you do this:
{
"colors": {
"primary": "rgb(65, 0, 153)",
"primaryDark": "rgb(50, 0, 120)"
},
"button": {
"background": "[[colors.primary]]",
"hoverBackground": "[[colors.primaryDark]]"
}
}
See those [[...]] markers? Those get resolved to the actual values. Change colors.primary once, and it updates everywhere it's referenced.
The resolution happens in resolveConfig():
export const resolveConfig = (widgetConfig) =>
resolveJson(widgetConfig) as WidgetConfig
This runs after all the merging is done, so the final merged config gets its variables resolved. Neat.
Integrating With Material UI Theme
The config system needed to play nice with Material UI's theming. This meant:
- Config structure must match (or extend) Material UI's theme structure
- Pass config to Material UI's
createTheme() - Make config available to styled-components too
The theme provider at src/providers/theme/index.tsx handles this:
export const ThemeProvider = observer(({children}) => {
const {theme: themeConfig} = useConfig()
const [theme] = useTheme({theme: themeConfig})
return (
<MuiBaseThemeProvider injectFirst>
<StyledComponentsThemeProvider theme={theme}>
<MuiThemeProvider theme={theme}>
{children}
</MuiThemeProvider>
</StyledComponentsThemeProvider>
</MuiBaseThemeProvider>
)
})
Three providers, all getting the same theme object. This means:
- Material UI components get the theme
- Styled-components get the theme
- Custom components get the theme
Everyone's happy.
TypeScript Declaration Merging: Extending MUI Types
But there's a problem. Material UI's theme has specific types. Custom config adds custom properties. TypeScript isn't happy.
The solution? Declaration merging. TypeScript lets you "reopen" existing interfaces and add to them. Check out typings/theme.d.ts:
declare module '@mui/material/styles' {
interface BreakpointOverrides {
xxl: true;
xxxl: true;
}
interface Theme extends AdaptTheme {
}
interface ThemeOptions extends AdaptTheme {
}
interface Palette {
neutral: Palette['primary']
}
interface CommonColors {
silver: string
}
}
declare module 'styled-components' {
export interface DefaultTheme extends Theme {
}
}
This tells TypeScript: "Hey, Material UI's theme? It also has these properties now." And TypeScript just... accepts it. No errors, full type safety for custom config.
This pattern is documented in Material UI's own docs for adding custom colors, and it works perfectly for extending the entire theme with custom config systems.
Authorization In Config
One interesting use case: authorization. Components need to authenticate with backend APIs, but auth mechanisms vary by deployment.
Auth configuration goes in the config:
{
"fetch": {
"authorization": {
"source": {
"type": "cookie",
"attributeName": "Authorization"
},
"destination": {
"type": "header",
"attributeName": "Authorization"
}
}
}
}
This says: "Get the auth token from a cookie named 'Authorization', and put it in a header named 'Authorization'."
Then a custom useFetch hook reads this config and automatically applies it:
const config = useConfig()
const authSource = config.fetch.authorization.source
const authDest = config.fetch.authorization.destination
// Read token from cookie
const token = readCookie(authSource.attributeName)
// Add to request headers
headers[authDest.attributeName] = token
Change the config, change how auth works. No code changes required. This was huge for testing different auth mechanisms without rebuilding.
Internationalization: Labels In Config
Similarly, i18n configuration integrates into the config. It specifies:
- Default language
- Fallback language
- Where to load translations from
{
"i18n": {
"lng": "EN",
"fallbackLng": "EN",
"backend": {
"http": {
"loadPath": "/api/translations/{{lng}}"
}
}
}
}
Throughout the config, instead of hardcoded text, use label keys:
{
"elements": {
"layout": {
"widget": {
"header": {
"title": {
"text": "widget.header.title"
}
},
"toolbar": {
"close": {
"hint": "common.button.hint.close"
}
}
}
}
}
}
These keys get resolved by i18n-next at runtime. Change the language, all labels change. Have a client that speaks German? Point their config to German translations. Done.
Icons In Config: The SVG to Base64 Adventure
Here's where I maybe went a bit overboard. I wanted icons in the config too.
The challenge: Many components have icons hardcoded in CSS (background images, pseudo-elements, etc.). How do you make those configurable?
The solution I came up with: Convert React icon components to Base64-encoded SVG URLs, then use those in CSS.
The config would specify icon names:
{
"elements": {
"layout": {
"widget": {
"header": {
"closeButton": {
"icon": {
"name": "FaRegWindowClose",
"color": "rgb(255, 255, 255)",
"size": "1.25rem"
}
}
}
}
}
}
}
Then in code:
- Import the icon from React Icons:
import {FaRegWindowClose} from 'react-icons/fa' - Render it to an SVG string
- Encode it as Base64
- Create a
url("data:image/svg+xml;base64,...")string - Use it in CSS
This worked! But it was slow. Rendering dozens of icons to Base64 on every render was painful.
So I added caching. First render, generate the URL and cache it. Subsequent renders, use the cached version. Much faster.
Looking back, this might have been overkill. But it worked, and it meant truly everything visual could be configured, even icons in CSS.
The Demo That Can Buy Management
With all pieces in place, here's a compelling demo scenario:
- Show the application running with default branding
- Open the config JSON file in an editor
- Change the primary color from purple to orange
- Save the file
- Refresh the page
- The entire application is now orange
Then demonstrate live changes: colors, fonts, sizes, icons: all in real-time just by editing JSON. No rebuilds, no deployments, just instant changes.
The question that inevitably comes: "Can clients customize their own branding?"
Absolutely. That's the entire point of the system.
Using Config with the Declarative Forms
Remember the declarative form system I wrote about in the previous article? The Config system plays perfectly with it.
Form configurations can be stored in the config and loaded dynamically:
{
"widgets": {
"entityForm": {
"attributes": [
{
"name": "firstName",
"type": "text",
"label": "form.field.firstName",
"required": true
}
]
}
}
}
This means the same form component can render different forms based on configuration. One client needs a 5-field form, another needs 20 fields? Just different configs, same code.
The form engine reads from the config, applies the declarative dependencies, and everything just works together. The multi-layer system means library defaults provide basic form styling, application config customizes for the product, and runtime config adapts per client.
What This Pattern Enables
The power shows up in unexpected ways:
Instant visual updates: Marketing changed their mind about the color scheme at 4 PM on Friday? Update the config JSON, refresh. Five minutes, not five days.
Per-client customization: Client says "we need our logo in the header." Add header.logo.url to their config. No custom build, no special branch, just configuration.
Safe feature rollout: Launch a feature but keep the kill switch ready. One line in production config disables it if things go wrong. No emergency deploy.
Designer collaboration: Designers can iterate on spacing, colors, icon sizes by editing JSON and seeing results immediately. No waiting for developer availability.
Testing variations: Does a bigger button convert better? Load different configs for different user groups. Same deployed code, different experience.
The Costs (Being Honest)
Of course, this wasn't without downsides:
Complexity: The config file grew to over 700 lines. Finding what you need isn't always obvious. Requires good documentation and examples.
Merge Bugs: Deep merging objects is tricky. Several bugs emerged where configs didn't merge as expected, especially with arrays. Took time to iron out.
Type Safety: TypeScript helps, but config is ultimately JSON loaded at runtime. Type errors become runtime errors. Caught a few in production during development.
Learning Curve: The config structure is custom, not standard React or Material UI. Takes time to learn.
Performance: Loading and merging large configs does have overhead. Not much, but it's there. And that icon encoding system I built? Definitely added some milliseconds to initial render.
Over-Configuration: Not everything benefits from being in config. Finding the right balance is trial and error.
Worth these costs? Depends on the use case. For multi-tenant applications or platforms needing runtime flexibility, absolutely. For smaller single-tenant apps, might be overkill.
What I'd Do Differently
If I were starting over:
Smaller Default Config: 700 lines is too much. I'd be more selective about what goes in by default. Maybe split it into modules.
Config Editor UI: A visual editor for configs (like Material UI's theme editor) would be valuable for non-technical users. Never built one, but it's a logical next step.
Validation: Runtime config validation would catch errors earlier. Invalid configs making it to production is a real risk. JSON Schema or similar would help.
Better Documentation: Documentation for the config structure needs extensive examples for every major section.
Lazy Loading: Load config sections on-demand instead of all at once. Most pages don't need the entire config.
The Real Win: Flexibility
The biggest benefit is the flexibility.
When someone asks "Can we change X?" the answer is "Yes, in the config."
Need to disable a buggy feature in production? Config flag, done in seconds.
Want to test a new design with 10% of users? A/B test via config.
The config system transforms a rigid, hard-coded application into a flexible, configurable platform. That flexibility is what matters most.
Complementary Tools; Not Alternatives
Platforms like ConfigCat, LaunchDarkly, Split.io, and Flagsmith are often mentioned in the same breath as runtime configuration. They're excellent at what they do, feature flags with targeting rules, percentage rollouts, and analytics; but they solve one slice of the problem.
Multi-layer config is a whole-system approach. Feature flags are one dimension: theming, typography, icons, layout, i18n labels, component behavior, and form structure are others. A feature flag service tells you whether to show a feature. Multi-layer config tells you how the entire UI should
look and behave.
These tools aren't alternatives, they're complementary. ConfigCat or LaunchDarkly can feed into the runtime config layer as a data source:
// Feature flag platform as one input to the runtime layer
const runtimeConfig = {
features: {
newDashboard: await configCat.getValueAsync('newDashboard', false),
betaExport: await configCat.getValueAsync('betaExport', false),
},
// Everything else: theming, layout, icons; lives here too
theme: {palette: {primary: {main: tenantBranding.primaryColor}}},
icons: tenantBranding.iconSet,
i18n: tenantBranding.labels,
}
The same applies to other tools in the space:
TinaCMS / Contentful / Strapi: Content management. They handle text, images, pages. Multi-layer config handles application behavior and styling. Use both: CMS for content, config system for everything else.
Firebase Remote Config / AWS AppConfig: Cloud-based config with versioning and rollback. They can serve as the backend behind the runtime config URL, the config system doesn't care where the JSON comes from.
The key difference: feature flag platforms give you booleans and strings. Multi-layer config gives you a deeply merged, typed configuration object that drives your entire component tree, from button border radius to form field visibility to icon mappings. Use the specialized tools for what
they're best at, and let the config system unify everything into one coherent runtime shape.
A Complete Example
Here's a real example from examples/01-multi-layer-config:
import {Widget} from 'adaptui'
function App() {
return (
<Widget
// Application-level config
config={{
theme: {
palette: {
primary: {main: '#1976d2'}
}
}
}}
// Runtime config from URL
configUrl="/api/config"
// Library defaults used for everything else
>
<MyApplication/>
</Widget>
)
}
The Widget component automatically wraps everything in ConfigProvider, which loads and merges all the configs. Then any component inside can access the merged config:
import {useConfig} from 'adaptui'
function MyComponent() {
const config = useConfig()
return (
<div style={{backgroundColor: config.theme.palette.primary.main}}>
{/* Styled from config */}
</div>
)
}
Simple API, powerful flexibility.
Wrapping Up
Multi-layer configuration trades initial complexity for long-term flexibility. The setup takes time: designing the config structure, implementing merging logic, integrating with your component library. But once built, changes that used to take hours happen in minutes.
The pattern works best when you need runtime adaptability. For static applications with one deployment and no variation, it's overkill. For platforms serving multiple clients or needing frequent visual updates, it's transformative.
The ideas here are patterns, not prescriptions. Two layers might be enough. Your merging strategy might differ. What matters is separating configuration from code and loading it at runtime.
Hopefully these patterns help someone build their own version and avoid the pitfalls discovered along the way.
The Code
The full implementation is on GitHub at github.com/NazarUsik/AdaptUI.
Key files:
- Config store and provider:
src/providers/config - Default config:
src/providers/config/config/index.ts - Theme integration:
src/providers/theme - TypeScript extensions:
typings/theme.d.ts - Working example:
examples/01-multi-layer-config
Is it perfect? No. Is it the right choice for every project? Probably not. But if you're facing multiple rebrandings, multi-tenant requirements, or just want your UI to be more flexible, the multi-layer config pattern is worth considering.
Author: Nazar Usik
GitHub: AdaptUI
Related: This pattern works especially well with the declarative form dependencies
and component wrapper pattern described in other articles.

Top comments (0)