The mandate was to build a series of data visualization dashboards for an Internal Developer Platform (IDP). Multiple engineering teams needed to display metrics sourced from a central data warehouse, but each team had its own branding, feature requirements, and, critically, distinct GraphQL API endpoints for different environments (staging, canary, production). The initial approach of creating separate, statically configured React applications for each team was immediately identified as a path to operational chaos. A new deployment for a simple theme color change or an endpoint update is an anti-pattern that slows down development velocity and increases risk.
The core pain point was the tight coupling between configuration and deployment artifacts. Our goal became to build a single, universal front-end application that could configure itself entirely at runtime based on the context of the tenant (the engineering team) accessing it. This meant the API endpoint, the visual theme, and feature flags all needed to be fetched from a remote, authoritative source before the application even rendered.
Our backend infrastructure already relied heavily on HashiCorp Consul for service discovery and its Key/Value (KV) store for service-level configuration. Extending this to serve front-end configuration was a pragmatic choice. It avoided introducing another moving part and leveraged existing operational expertise. The stack chosen was React, with Apollo Client for GraphQL communication and Styled-components for theming, as they offered the flexibility needed for this dynamic approach.
The architecture centers around a bootstrap process. The React application shell loads first, but instead of rendering the main dashboard, it triggers a configuration fetch from a dedicated backend-for-frontend (BFF) endpoint. This BFF queries Consul KV for the tenant-specific configuration JSON, which includes the GraphQL endpoint URL, a complete theme object for Styled-components, and any feature flags. Only after this configuration is successfully retrieved and loaded into the React context does the application initialize Apollo Client and the Styled-components ThemeProvider
and proceed to render the actual data-driven UI.
sequenceDiagram participant Browser participant AppShell as React App Shell participant BFF as Backend-for-Frontend (Node.js) participant Consul as Consul KV Store participant GraphQL as GraphQL Gateway Browser->>AppShell: Initial Load (e.g., /dashboards/team-alpha) AppShell->>BFF: GET /api/config?tenant=team-alpha activate BFF BFF->>Consul: GET /v1/kv/config/dashboards/team-alpha activate Consul Consul-->>BFF: Return JSON config (API URL, Theme, Flags) deactivate Consul BFF-->>AppShell: 200 OK with Config Payload deactivate BFF AppShell->>AppShell: Parse Config AppShell->>AppShell: Initialize Apollo Client with dynamic URL AppShell->>AppShell: Initialize Styled-components ThemeProvider AppShell->>GraphQL: Execute GraphQL Query (e.g., fetch metrics) activate GraphQL GraphQL-->>AppShell: Return Data Warehouse Metrics deactivate GraphQL AppShell->>Browser: Render fully configured and themed dashboard
Structuring Configuration in Consul
A well-defined structure in the Consul KV store is critical for maintainability. We settled on a path-based schema that segments configuration by application, tenant, and environment. For our dashboard, the structure looks like this:
config/dashboards/[TENANT_ID]/[ENVIRONMENT]/
Under this prefix, we store individual keys for different parts of the configuration.
-
config/dashboards/team-alpha/production/core
: This key holds the primary configuration JSON. -
config/dashboards/team-bravo/staging/core
: Configuration for a different team in their staging environment.
Here is an example of the JSON data stored in config/dashboards/team-alpha/production/core
:
{
"service": {
"graphqlEndpoint": "https://api.team-alpha.prod.internal/graphql"
},
"theme": {
"colors": {
"primary": "#0A4C95",
"secondary": "#5E97D1",
"background": "#F0F2F5",
"text": "#121212",
"success": "#2E7D32",
"error": "#C62828",
"chartLine": "#1E88E5"
},
"fonts": {
"body": "Inter, sans-serif",
"heading": "Roboto Slab, serif"
},
"spacing": {
"small": "8px",
"medium": "16px",
"large": "24px"
}
},
"featureFlags": {
"enableRealtimeUpdates": true,
"showAdvancedFilters": false
}
}
A common mistake is to store each value as a separate key. While this offers granular updates, it results in multiple HTTP requests to Consul to fetch a complete configuration, increasing startup latency. Bundling related configuration into a single JSON object per tenant/environment is far more efficient for this use case.
The Backend-for-Frontend: A Secure Bridge to Consul
Exposing Consul directly to the browser is a security risk and an architectural anti-pattern. The BFF acts as a secure intermediary. It’s a simple Node.js Express server responsible for resolving the tenant, fetching the configuration from Consul, and exposing it through a clean API.
This service requires the consul
and express
packages.
// file: server.js
const express = require('express');
const Consul = require('consul');
const morgan = require('morgan'); // For logging
// --- Configuration ---
// In a real-world project, these should come from environment variables.
const CONSUL_HOST = process.env.CONSUL_HOST || 'localhost';
const CONSUL_PORT = process.env.CONSUL_PORT || '8500';
const PORT = process.env.PORT || 3001;
const CONFIG_KV_PREFIX = 'config/dashboards';
const app = express();
const consul = new Consul({
host: CONSUL_HOST,
port: CONSUL_PORT,
promisify: true, // Use promises instead of callbacks
});
// --- Middleware ---
app.use(morgan('tiny')); // Basic request logging
// --- API Endpoint ---
app.get('/api/config', async (req, res) => {
const { tenantId, env = 'production' } = req.query;
if (!tenantId) {
console.error('Configuration request failed: tenantId is required.');
return res.status(400).json({ error: 'tenantId query parameter is required' });
}
const key = `${CONFIG_KV_PREFIX}/${tenantId}/${env}/core`;
console.log(`[Config BFF] Fetching configuration for key: ${key}`);
try {
const result = await consul.kv.get(key);
if (!result) {
console.warn(`[Config BFF] No configuration found for key: ${key}`);
return res.status(404).json({ error: `Configuration not found for tenant '${tenantId}' in environment '${env}'` });
}
// Consul stores the value as a base64 encoded string if fetched via API,
// but the node-consul library decodes it for us. It's just a string.
const config = JSON.parse(result.Value);
console.log(`[Config BFF] Successfully fetched configuration for tenant '${tenantId}'`);
return res.status(200).json(config);
} catch (error) {
console.error(`[Config BFF] Error fetching configuration from Consul for key '${key}':`, error);
// Distinguish between Consul connection errors and other errors
if (error.code === 'ECONNREFUSED') {
return res.status(503).json({ error: 'Configuration service unavailable', details: 'Could not connect to Consul.' });
}
return res.status(500).json({ error: 'Internal Server Error', details: error.message });
}
});
app.listen(PORT, () => {
console.log(`[Config BFF] Server listening on port ${PORT}`);
console.log(`[Config BFF] Connecting to Consul at ${CONSUL_HOST}:${CONSUL_PORT}`);
});
This server is production-grade. It includes basic logging with morgan
, robust error handling for missing parameters, and specific checks for when configuration is not found (404
) versus when Consul is unreachable (503
). This distinction is vital for front-end error handling.
The React Bootstrap Process
On the client-side, the application must be designed to exist in three states: LOADING
, SUCCESS
, or ERROR
. We encapsulate this logic in a top-level provider component, let’s call it AppConfigProvider
.
// file: src/contexts/AppConfigContext.jsx
import React, { createContext, useState, useEffect, useContext } from 'react';
// Define the shape of our config for better type safety
/*
interface AppConfig {
service: { graphqlEndpoint: string };
theme: object;
featureFlags: Record<string, boolean>;
}
interface AppConfigState {
config: AppConfig | null;
status: 'LOADING' | 'SUCCESS' | 'ERROR';
error: Error | null;
}
*/
const AppConfigContext = createContext(undefined);
// A simple utility to extract tenant from hostname or path,
// this would be more robust in a real application.
const getTenantId = () => {
// e.g., team-alpha.dashboards.mycompany.com
const parts = window.location.hostname.split('.');
if (parts.length > 2 && parts[1] === 'dashboards') {
return parts[0];
}
// Fallback for local dev: e.g., localhost:3000?tenant=team-alpha
const params = new URLSearchParams(window.location.search);
return params.get('tenantId') || 'default-tenant';
};
export const AppConfigProvider = ({ children }) => {
const [state, setState] = useState({
config: null,
status: 'LOADING',
error: null,
});
useEffect(() => {
const fetchConfig = async () => {
try {
const tenantId = getTenantId();
// In development, the BFF runs on a different port.
// In production, this would be proxied to the same domain.
const response = await fetch(`/api/config?tenantId=${tenantId}`);
if (!response.ok) {
const errorPayload = await response.json();
throw new Error(`Failed to fetch config: ${response.status} - ${errorPayload.error || 'Unknown error'}`);
}
const configData = await response.json();
// Basic validation of the fetched config.
if (!configData.service || !configData.service.graphqlEndpoint) {
throw new Error("Invalid configuration received: missing graphqlEndpoint.");
}
setState({ config: configData, status: 'SUCCESS', error: null });
} catch (err) {
console.error("App Bootstrap Error:", err);
setState({ config: null, status: 'ERROR', error: err });
}
};
fetchConfig();
}, []); // Run only once on component mount
return (
<AppConfigContext.Provider value={state}>
{children}
</AppConfigContext.Provider>
);
};
export const useAppConfig = () => {
const context = useContext(AppConfigContext);
if (context === undefined) {
throw new Error('useAppConfig must be used within an AppConfigProvider');
}
return context;
};
This provider fetches the configuration and makes it available via the useAppConfig
hook. The crucial part is how the main application component consumes this context.
Dynamically Initializing Providers
The App.js
component becomes a gatekeeper. It uses the useAppConfig
hook to decide what to render. It shows a loading spinner during the fetch, an error message on failure, and only when the configuration is successfully loaded does it proceed to initialize and render the ApolloProvider
and ThemeProvider
.
A common pitfall is to define the Apollo Client instance outside the component. This prevents dynamic configuration. The client instance must be created after the configuration is available, often using React.useMemo
to prevent re-creation on every render.
// file: src/App.js
import React, { useMemo } from 'react';
import { ApolloProvider, ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
import { ThemeProvider } from 'styled-components';
import { useAppConfig } from './contexts/AppConfigContext';
import Dashboard from './components/Dashboard';
import GlobalStyles from './styles/GlobalStyles';
// A placeholder component for loading and error states
const AppStateIndicator = ({ status, error }) => {
if (status === 'LOADING') {
return <div>Loading configuration...</div>;
}
if (status === 'ERROR') {
return (
<div>
<h1>Application Error</h1>
<p>Could not load application configuration.</p>
<pre>{error?.message}</pre>
</div>
);
}
return null;
};
const AppCore = () => {
const { config, status, error } = useAppConfig();
// Create the Apollo Client instance only when config is available.
// useMemo ensures the client is not recreated on every render unless the endpoint changes.
const apolloClient = useMemo(() => {
if (status !== 'SUCCESS' || !config) {
return null;
}
console.log(`Initializing Apollo Client for endpoint: ${config.service.graphqlEndpoint}`);
const httpLink = createHttpLink({
uri: config.service.graphqlEndpoint,
});
return new ApolloClient({
link: httpLink,
cache: new InMemoryCache(),
});
}, [config, status]);
if (status !== 'SUCCESS' || !apolloClient) {
return <AppStateIndicator status={status} error={error} />;
}
// The config is loaded, and the client is ready.
// Now we can set up the providers and render the main app.
return (
<ApolloProvider client={apolloClient}>
<ThemeProvider theme={config.theme}>
<GlobalStyles />
<Dashboard />
</ThemeProvider>
</ApolloProvider>
);
};
// The root component wraps the core application with the config provider.
function App() {
return (
<AppConfigProvider>
<AppCore />
</AppConfigProvider>
);
}
export default App;
With this structure, the Dashboard
component and all its children can safely assume that Apollo Client is configured and that a theme is available via Styled-components’ context.
A sample component demonstrates how the theme is consumed:
// file: src/components/MetricCard.jsx
import React from 'react';
import styled from 'styled-components';
const CardWrapper = styled.div`
background: white;
border-radius: 8px;
padding: ${props => props.theme.spacing.medium};
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border-left: 5px solid ${props => props.theme.colors.primary};
`;
const Title = styled.h3`
font-family: ${props => props.theme.fonts.heading};
color: ${props => props.theme.colors.text};
margin: 0 0 ${props => props.theme.spacing.small} 0;
`;
const Value = styled.p`
font-family: ${props => props.theme.fonts.body};
color: ${props => props.theme.colors.primary};
font-size: 2rem;
font-weight: bold;
margin: 0;
`;
const MetricCard = ({ title, value }) => (
<CardWrapper>
<Title>{title}</Title>
<Value>{value}</Value>
</CardWrapper>
);
export default MetricCard;
This MetricCard
component is entirely decoupled from any specific color or font. It adapts its appearance based on the theme object injected by the ThemeProvider
, which itself was sourced from Consul.
The final result is a highly decoupled front-end system. Onboarding a new team, “team-charlie,” now involves two steps, neither of which requires a front-end code change or deployment:
- DevOps adds a new key
config/dashboards/team-charlie/production/core
in Consul with their desired theme and API endpoint. - The team accesses their dashboard via
team-charlie.dashboards.mycompany.com
.
The application handles the rest. This architecture scales organizationally. It empowers teams by decoupling their configuration lifecycle from the platform’s release cycle.
However, this design is not without trade-offs. The primary drawback is the introduction of a blocking network request at application startup, which increases the Time to Interactive (TTI). For an internal tool, this is often an acceptable compromise for the gain in flexibility. A potential mitigation is to cache the last known good configuration in localStorage
and use it as a stale-while-revalidate strategy, which would provide an instant (though potentially outdated) render on subsequent visits while fetching the latest configuration in the background. Furthermore, this approach shifts runtime errors from build time to, well, runtime. A malformed theme JSON in Consul or a schema mismatch between the front-end queries and a team’s specific GraphQL endpoint can break the application for that tenant. This necessitates robust validation on the configuration payload and a solid monitoring strategy to catch such failures proactively.