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.
Problem
Reflex compiles Python page code into a static JS bundle at
reflex export. Anyos.environread (orpydantic_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
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 readingdb_passwordis forced to provide it just to instantiate the class. Teams end up sprinkling dummydb_password=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxvalues into Dockerfiles, which is a code smell pointing at exactly this design gap.Why it's a class of bug, not a one-off
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 atreflex export. Browser-visible. Safe to read anywhere.rx.BackendEnvβ values stay backend-only. Instantiation raises duringreflex 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_PUBLIC_*prefix β bundled; everything else β server-only. Compile-time check.runtimeConfig.publicvsruntimeConfig.$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:
BaseSettingssubclasses (e.g.FrontendBundleEnv,BackendEnv)BUILDING_FRONTEND_BUNDLE=1env set in the Dockerfile for thereflex exportstepget_backend_env()raises if that flag is setget_frontend_bundle_env()directly at module load (no state var) since values are constant per-bundleIt 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.