Skip to content

Add native env var support: bundle-bound (baked at build) vs backend-only (runtime) #6564

@larsblumberg

Description

@larsblumberg

Problem

Reflex compiles Python page code into a static JS bundle at reflex export. Any os.environ read (or pydantic_settings.BaseSettings() instantiation) that happens during page evaluation gets its return value baked into the static bundle — the value the env had at build time becomes a string literal in .web/build/client/*.js.

This is structurally inherent to the architecture: the same Python module runs at both build (export) and runtime (server), and there's nothing in the framework that distinguishes "values safe to embed in the browser bundle" from "values that must stay backend-only."

Minimal repro

# config.py
class Settings(BaseSettings):
    public_api_url: str
    db_password: str

# pages/some_page.py
def _endpoint_card() -> rx.Component:
    return rx.code(_api_url())          # called during page evaluation

def _api_url() -> str:
    return get_settings().public_api_url

get_settings() reads env vars during page evaluation. In a typical build pipeline, the real per-deployment values aren't set at build time (they come from runtime container env or secret manager), so a placeholder/dummy gets baked into the bundle. The wrong value silently ships to every browser, with no warning, no compile error, no test failure.

Worse, the same Settings() call validates all fields — so a build that has no business reading db_password is forced to provide it just to instantiate the class. Teams end up sprinkling dummy db_password=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx values into Dockerfiles, which is a code smell pointing at exactly this design gap.

Why it's a class of bug, not a one-off

  • Any third-party library that reads env at import time has the same exposure.
  • Any helper that lazily reads config and gets called during page evaluation has the same exposure.
  • Refactors that move a backend-only call into a render path silently introduce leaks.
  • Adding new env vars is a judgment call ("will this end up in the bundle?") that's invisible to the type system.

The mental model "I'm just reading an env var" doesn't match the reality "this might end up in a CDN-cached JS file forever."

Proposed solution

Make the bundle/backend split a first-class concept, the way Next.js, Nuxt, and SvelteKit do — two pydantic-style base classes provided by Reflex:

  • rx.FrontendBundleEnv — values are baked into the JS bundle at reflex export. Browser-visible. Safe to read anywhere.
  • rx.BackendEnv — values stay backend-only. Instantiation raises during reflex export, so accidental reads at page-evaluation time fail loud with a useful traceback instead of silently leaking into the static bundle.

The choice between "frontend bundle" and "backend" is a deployment-level decision that doesn't belong in scattered helper logic — it belongs on the env var itself. Putting it in the type lets every reader see, at the call site, which side of the split they're on.

A concrete implementation sketch (~30 lines, no new dependencies, including .get() cached-singleton accessors and the optional bundle-scan defense in depth) is in a follow-up comment below.

Prior art

  • Next.js: NEXT_PUBLIC_* prefix → bundled; everything else → server-only. Compile-time check.
  • Nuxt: runtimeConfig.public vs runtimeConfig.
  • SvelteKit: $env/static/public / $env/static/private / $env/dynamic/public / $env/dynamic/private — the import path itself encodes the classification.

All three solve the same structural problem the same way: make "is this safe to ship to the browser?" a property of the variable, not a judgment call at every read site.

Workaround until upstream support exists

The same shape can be built manually today:

  • Two BaseSettings subclasses (e.g. FrontendBundleEnv, BackendEnv)
  • A BUILDING_FRONTEND_BUNDLE=1 env set in the Dockerfile for the reflex export step
  • get_backend_env() raises if that flag is set
  • View code reads get_frontend_bundle_env() directly at module load (no state var) since values are constant per-bundle

It works, but every Reflex user with a real deployment will hit this same shape eventually. Built-in support would catch the bug at framework level rather than relying on each team to reinvent it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions