Skip to main content
All posts

Type-Safe Environment Variables Across Monorepo Boundaries: Sharing Secrets Between Astro, React, and FastAPI Without Leaking to the Browser

2 June 2026

Type-Safe Environment Variables Across Monorepo Boundaries: Sharing Secrets Between Astro, React, and FastAPI Without Leaking to the Browser

I’ve watched too many teams solve this the wrong way. Either they duplicate .env files across three services and pray they stay in sync, or they accidentally ship a DATABASE_URL to the browser and spend three hours at 2 AM rotating credentials. In CitizenApp, we handle environment variables across Astro (server), React 19 (client), and FastAPI (backend) with TypeScript namespaces and build-time injection. It’s boring and defensive—exactly what you want.

The core insight: your build system should enforce which variables reach which runtime. Not docs, not conventions. The compiler.

The Problem: Three Runtimes, One Config Nightmare

A monorepo typically has three environments:

  1. Astro server (Node.js) — can safely hold database URLs, API keys
  2. React 19 bundle (browser) — can only have public endpoints and feature flags
  3. FastAPI backend (Python) — has its own secrets, database access

Most teams keep three .env files and hope. Or they dump everything into a single config file and rely on tree-shaking to strip secrets—which doesn’t work when your bundler doesn’t understand variable access patterns.

The result: constant audits, leaked credentials, and that sinking feeling when you realize your Stripe secret went into Vercel’s build output.

I prefer a TypeScript-first approach where the type system prevents you from accessing database credentials in the browser. Not at runtime. At compile time.

The Solution: Namespace-Based Variable Scoping

Here’s the pattern. Create a shared TypeScript configuration file that lives in your monorepo root or a shared package:

// packages/config/env.ts

/**
 * Server-only variables (Astro + build-time FastAPI config)
 * These NEVER get bundled into browser code
 */
export namespace ServerEnv {
  export const DATABASE_URL = process.env.DATABASE_URL || "";
  export const FASTAPI_SECRET_KEY = process.env.FASTAPI_SECRET_KEY || "";
  export const JWT_SECRET = process.env.JWT_SECRET || "";
  export const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || "";
}

/**
 * Shared variables (available to all runtimes)
 * Public URLs, endpoints, feature flags
 */
export namespace SharedEnv {
  export const API_ENDPOINT = process.env.PUBLIC_API_ENDPOINT || "http://localhost:8000";
  export const CLERK_PUBLISHABLE_KEY = process.env.PUBLIC_CLERK_PUBLISHABLE_KEY || "";
  export const ANTHROPIC_API_KEY = process.env.PUBLIC_ANTHROPIC_API_KEY || "";
}

/**
 * Client-only variables (React 19 only)
 * Set at build time, included in bundle
 */
export namespace ClientEnv {
  export const FEATURE_FLAG_BETA = process.env.PUBLIC_FEATURE_BETA === "true";
  export const ANALYTICS_KEY = process.env.PUBLIC_ANALYTICS_KEY || "";
}

// Type guard: prevent accidental server variable access in client code
export type ClientSafeEnv = typeof SharedEnv & typeof ClientEnv;

The discipline here: ServerEnv variables must never be imported into client-side code. Your bundler and TypeScript can’t fully enforce this alone, but we’ll add guardrails.

Astro: Server-Only Variable Injection

In your Astro config, load server variables early:

// astro.config.mjs

import { defineConfig } from "astro/config";
import react from "@astrojs/react";

export default defineConfig({
  integrations: [react()],
  
  vite: {
    define: {
      // Inject PUBLIC_* variables ONLY
      "process.env.PUBLIC_API_ENDPOINT": JSON.stringify(process.env.PUBLIC_API_ENDPOINT),
      "process.env.PUBLIC_CLERK_PUBLISHABLE_KEY": JSON.stringify(process.env.PUBLIC_CLERK_PUBLISHABLE_KEY),
    },
  },
});

In your Astro page/component, use ServerEnv freely:

// src/pages/api/stripe-webhook.astro

import { ServerEnv } from "@config/env";
import type { APIRoute } from "astro";

export const POST: APIRoute = async ({ request }) => {
  const signature = request.headers.get("stripe-signature")!;
  
  // Safe: This runs on Node.js, not in browser
  const event = await stripe.webhooks.constructEvent(
    await request.text(),
    signature,
    ServerEnv.STRIPE_SECRET_KEY  // ✓ Server-only
  );
  
  return new Response(JSON.stringify({ ok: true }));
};

Astro will only bundle what you explicitly pass to client components.

React 19: Build-Time Variable Binding

For React components, you must use environment variables that are prefixed with PUBLIC_ (Vite standard). I create a hook that enforces the type boundary:

// apps/web/src/hooks/useEnv.ts

import { SharedEnv, ClientEnv } from "@config/env";

/**
 * Type-safe hook for accessing only client-safe variables
 * TypeScript will error if you try to access ServerEnv here
 */
export function useEnv() {
  return {
    apiEndpoint: SharedEnv.API_ENDPOINT,
    clerkKey: SharedEnv.CLERK_PUBLISHABLE_KEY,
    betaFeatureEnabled: ClientEnv.FEATURE_FLAG_BETA,
  };
}

Usage in a component:

// apps/web/src/components/FeatureGate.tsx

import { useEnv } from "../hooks/useEnv";

export function FeatureGate() {
  const env = useEnv();
  
  if (!env.betaFeatureEnabled) {
    return <div>Feature coming soon</div>;
  }
  
  return (
    <div>
      {/* Safe: apiEndpoint is public, only for calling /api routes */}
      <button onClick={() => fetch(`${env.apiEndpoint}/feature`)}>
        Try Beta
      </button>
    </div>
  );
}

This pattern breaks if someone tries to import ServerEnv directly—they’ll get a runtime error because those variables won’t be defined in the browser.

FastAPI: Server Configuration at Build Time

For Python, we generate a configuration module during CI/CD:

# backend/app/config.py

import os
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str = os.getenv("DATABASE_URL", "postgresql://localhost/app")
    jwt_secret: str = os.getenv("JWT_SECRET", "dev-secret")
    api_endpoint: str = os.getenv("PUBLIC_API_ENDPOINT", "http://localhost:3000")
    
    class Config:
        env_file = ".env"
        env_file_encoding = "utf-8"

settings = Settings()

In your GitHub Actions workflow, explicitly inject secrets:

# .github/workflows/deploy.yml

jobs:
  deploy:
    runs-on: ubuntu-latest
    env:
      DATABASE_URL: ${{ secrets.DATABASE_URL }}
      JWT_SECRET: ${{ secrets.JWT_SECRET }}
      FASTAPI_SECRET_KEY: ${{ secrets.FASTAPI_SECRET_KEY }}
      PUBLIC_API_ENDPOINT: https://api.citizenapp.com
    steps:
      - uses: actions/checkout@v4
      - name: Build and push backend
        run: docker build -t app:latest .

Notice: PUBLIC_* variables are hardcoded in the workflow (not secrets). DATABASE_URL comes from GitHub Secrets.

Preventing Accidents: ESLint Rules

Add an ESLint plugin to catch imports at lint time:

// .eslintrc.js in apps/web

module.exports = {
  rules: {
    "no-restricted-imports": [
      "error",
      {
        paths: [
          {
            name: "@config/env",
            importNames: ["ServerEnv"],
            message: "❌ ServerEnv cannot be imported in client code. Use SharedEnv or ClientEnv instead.",
          },
        ],
      },
    ],
  },
};

Run this in CI. If someone tries to import ServerEnv in a React component, the build fails. No surprises in production.

Gotcha: Build-Time Variable Freezing

Here’s what bit me: I initially assumed environment variables would update if I changed a .env file. They don’t. Once Astro or Vite builds, variables are baked into the bundle.

If you need to rotate a feature flag without redeploying, you must either:

  1. Fetch flags from your API at runtime (one extra network round-trip, worth it for security-sensitive toggles)
  2. Trigger a rebuild on GitHub Actions when you update a secret
  3. Use Cloudflare Workers to inject variables at request time (my preferred approach for CitizenApp)

For secrets like STRIPE_SECRET_KEY, this is actually a feature—immutable = harder to leak.

The Real Win: Sleeping Better

This setup means you can code-review environment variable changes. You can audit which services access what. You can catch process.env.DATABASE_URL in a client component before it ships.

At CitizenApp scale (9 AI features, multi-tenant, handling payment webhooks), this discipline eliminated credential leaks entirely. The TypeScript type system did the heavy lifting.

Use it. Your security team will thank you.

You might also like

Building something like this?

I build production-grade Python + React applications. Let's talk about your project.

Get in touch