Inject public runtime config into compiled frontend apps at container startup—no rebuilding for each environment.
clientshell provides a typed browser API for configuration and an ultra-fast Go injector that bridges the gap between static builds (Vite, Webpack) and dynamic environment variables.
Modern frontend tools pre-compile environment variables into the binary build. This forces a complete rebuild (CI/CD) when moving between staging and production. clientshell fixes this by:
- Defining a schema in TypeScript (via
@clientshell/coreor@clientshell/zod). - Generating a manifest during the build phase.
- Injecting variables at runtime using a Go binary (
/env-config.js). - Providing a typed API in the browser that requires no extra dependencies.
| Package | Description |
|---|---|
@clientshell/core |
DSL for schema definitions and the browser-side runtime reader. |
@clientshell/zod |
Adapter for using Zod to define and validate your config schemas. |
@clientshell/vite |
Vite plugin to output manifests and serve dev stubs. |
@clientshell/cli |
CLI for manual manifest generation and CI validation. |
injector |
Ultra-fast Go binary for container startup injection. |
import { defineSchema, string, boolean } from "@clientshell/core";
export const clientEnvSchema = defineSchema({
API_URL: string({ required: true }),
ENABLE_BETA: boolean({ defaultValue: false }),
});A. Vite (@clientshell/vite)
Vite natively imports .ts schema files:
import { clientshellPlugin } from "@clientshell/vite";
import { clientEnvSchema } from "./env.schema";
export default {
plugins: [clientshellPlugin({ schema: clientEnvSchema })]
};B. Webpack (@clientshell/webpack) & Rollup (@clientshell/rollup)
Because Webpack and Rollup config files run in plain Node.js and can't natively require .ts files, you point the plugin to a pre-generated manifest JSON file instead:
// webpack.config.cjs
const { ClientshellWebpackPlugin } = require("@clientshell/webpack");
module.exports = {
plugins: [
new ClientshellWebpackPlugin({
manifestPath: "./clientshell.manifest.json",
devValues: { API_URL: "http://localhost:3000" }
})
]
};(You generate clientshell.manifest.json using the CLI before running the bundler).
import { readEnvFromShape } from "@clientshell/core";
import { clientEnvSchema } from "./env.schema";
const env = readEnvFromShape(clientEnvSchema);
console.log(env.API_URL); // Fully typed stringFor development, the bundler plugins (Vite, Webpack) automatically serve or generate a stub env-config.js to the browser, so your code works exactly the same way it does in production.
pnpm install
pnpm build
pnpm dev --filter example-vite-basicTo use clientshell in production, you can use the clientshell image as a base for your own application in a multi-stage Dockerfile.
# 1. Build your app
FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
RUN pnpm install && pnpm build
# 2. Use clientshell as the runtime
FROM clientshell
# Copy files into the default clientshell root (/app/dist)
COPY --from=builder /app/dist /app/distBuild and run with environment variables:
docker build -t my-app .
# Run with custom port and API URL
docker run -p 9000:9000 \
-e CLIENTSHELL_PORT=9000 \
-e CLIENT_API_URL=https://api.example.com \
my-app| Variable | Default | Description |
|---|---|---|
CLIENTSHELL_PORT |
8080 |
The port Caddy listens on. |
CLIENTSHELL_ROOT |
/app/dist |
The root directory Caddy serves files from. |
CLIENTSHELL_MANIFEST |
/app/dist/clientshell.manifest.json |
Path to the manifest for the injector. |
CLIENTSHELL_DEBUG |
0 |
Set to 1 for verbose log output. |
License: MIT
