Engineering ~ 4 min read

Frontend Config Pattern

How to keep your React applications configurable.

In modern software development, especially for containerized applications, keeping your applications configurable through environment variables is crucial to adapt to the needs of different environments. In backend applications, this is straightforward since they run in the environment where the environment variables can be accessed directly. However, for frontend applications, this becomes trickier because a significant part of your application, if not all of it, runs on the client side, making configuration changes more difficult. With statically built frontends, you need to be particularly careful not to accidentally bake your configuration into your client-side code and lose the ability to modify certain parameters based on different environment needs without having to produce a separate build for each environment.

The frontend config pattern is an approach for keeping modern React applications configurable during runtime. While it can be adapted to other frontend frameworks, this article will focus on React.

For this approach to work, you need at least some part of your code to be executed on the server side. This could be a dedicated backend for your application paired with a static frontend, or a frontend framework that supports server-side rendering (SSR) like Next.js or React Router in framework mode.

SSR with useContext

The core idea of this pattern is to leverage React’s useContext hook to provide configuration values throughout your application. By executing some code on the server side, you can read environment variables and pass them down to your React components via a context provider.

In React Router with framework mode, this could look like this:

  1. Start by defining your configuration in a central place
config.ts
export const loadConfig = () => ({
BACKEND_URL: process.env.BACKEND_URL || "http://localhost:3000",
});
export type Config = ReturnType<typeof loadConfig>;
  1. Create a custom hook and context to access the configuration throughout your application
useConfig.tsx
import React, { type PropsWithChildren } from "react";
import { type Config } from "~/config";
const ConfigContext = React.createContext<Config | undefined>(undefined);
export const ConfigProvider: React.FC<
PropsWithChildren<{ config: Config }>
> = ({ config, children }) => {
return (
<ConfigContext.Provider value={config}>{children}</ConfigContext.Provider>
);
};
export const useConfig = (): Config => {
const context = React.useContext(ConfigContext);
if (!context) {
throw new Error("useConfig must be used within a ConfigProvider");
}
return context;
};
  1. Now at the root of your application, load the configuration on the server side and provide it to your app.
root.tsx
import React from "react";
import { ConfigProvider } from "~/useConfig";
import { loadConfig } from "~/config";
import App from "~/App";
export const loader = async () => {
const loadedConfig = loadConfig();
if (!loadedConfig) {
throw new Error("Config not loaded");
}
return { config: loadedConfig };
};
// ...
export default function App() {
const { config } = useLoaderData();
return (
<ConfigProvider config={config}>
<Outlet />
</ConfigProvider>
);
}
  1. And finally, use the useConfig hook anywhere in your application to access the configuration values.
import { useConfig } from "~/useConfig";
const MyComponent = () => {
const { BACKEND_URL } = useConfig();
return <div>Backend URL: {BACKEND_URL}</div>;
};

Fetch from /api

If your frontend application does not support server-side rendering and exclusively runs on the client side, you can still implement a similar pattern by fetching configuration from a dedicated API endpoint. This requires a backend that serves the configuration values. Since you cannot configure the backend URL via environment variables on the client side, the configuration should ideally be served from the same origin as your frontend application on a known path, e.g., /api/config.

Everything mostly stays the same, except your config.ts would only contain the type definition, and you would implement a fetch call to retrieve the configuration.

config.ts
export interface Config {
SOME_CONFIG: string;
}
export const fetchConfig = async (): Promise<Config> => {
const response = await fetch("/api/config");
if (!response.ok) {
throw new Error("Failed to fetch config");
}
return response.json();
};

Then you would fetch the configuration in your root component and provide it via the ConfigProvider as shown earlier.

root.tsx
import React, { useEffect, useState } from "react";
import { ConfigProvider } from "~/useConfig";
import { fetchConfig } from "~/config";
import App from "~/App";
export default function Root() {
const [config, setConfig] = useState<Config | null>(null);
useEffect(() => {
const loadConfig = async () => {
const loadedConfig = await fetchConfig();
setConfig(loadedConfig);
};
loadConfig();
}, []);
if (!config) {
return <div>Loading...</div>;
}
return (
<ConfigProvider config={config}>
<App />
</ConfigProvider>
);
}

That’s it! Thx Bye! 👋