Skip to content
36 changes: 36 additions & 0 deletions reflex/.templates/web/generate-shell.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Post-build script: generates a static SPA shell (build/client/index.html).
*
* With ssr:true, `react-router build` does not emit index.html because all
* HTML is rendered at request time. The production server (ssr-serve.js)
* serves this pre-built shell to regular users for instant load with zero
* SSR overhead; only bots go through the SSR path.
*
* The X-Reflex-Shell-Gen header tells the root loader to short-circuit and
* return { state: null } without contacting the Python backend.
*/
import { createRequestHandler } from "react-router";
import { writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

// Resolve paths relative to this file, not process.cwd().
const __dirname =
import.meta.dirname ?? dirname(fileURLToPath(import.meta.url));

const build = await import(join(__dirname, "build", "server", "index.js"));
const handler = createRequestHandler(build, "production");

const request = new Request("http://localhost/", {
headers: {
"User-Agent": "Mozilla/5.0 Chrome/120 (Shell Generator)",
"X-Reflex-Shell-Gen": "1",
},
});

const response = await handler(request);
const html = await response.text();

const outPath = join(__dirname, "build", "client", "index.html");
writeFileSync(outPath, html);
console.log("Generated build/client/index.html");
84 changes: 84 additions & 0 deletions reflex/.templates/web/ssr-serve.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Bot-aware SSR production server for Reflex apps.
*
* - Crawlers/bots receive fully server-side rendered HTML (SEO).
* - Regular users receive the static SPA shell (fast, zero SSR overhead).
*
* Used when `runtime_ssr=True` is set in the Reflex config.
*/
import { createRequestHandler } from "@react-router/express";
import express from "express";
import compression from "compression";
import { isbot } from "isbot";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

// Resolve all paths relative to *this file*, not process.cwd().
const __dirname =
import.meta.dirname ?? dirname(fileURLToPath(import.meta.url));

const clientDir = join(__dirname, "build", "client");
const serverEntry = join(__dirname, "build", "server", "index.js");

const buildModule = await import(serverEntry);

const app = express();
app.disable("x-powered-by");
app.use(compression());

// Static assets with content-hash filenames — cache immutably.
app.use(
"/assets",
express.static(join(clientDir, "assets"), { immutable: true, maxAge: "1y" }),
);

// Other static files (favicon, sitemap, etc.) — short cache.
app.use(express.static(clientDir, { maxAge: "1h" }));

// SSR request handler (React Router).
const ssrHandler = createRequestHandler({ build: buildModule });

// Read the static SPA shell into memory at startup (generated by generate-shell.mjs).
// Serving from memory avoids per-request filesystem access.
const shellPath = join(clientDir, "index.html");
const shellHtml = existsSync(shellPath)
? readFileSync(shellPath, "utf-8")
: null;
if (!shellHtml) {
console.warn(
"[ssr-serve] build/client/index.html not found — all requests will use SSR.",
);
}

app.all("*", (req, res, next) => {
const ua = req.headers["user-agent"] || "";

// Bots always get full server-side rendered HTML with state data.
// Also used as fallback when the static shell is unavailable.
if (isbot(ua) || !shellHtml) {
return ssrHandler(req, res, next);
}

// For regular users: only serve the SPA shell for initial document requests
// (browser navigating to a URL). React Router's .data requests (used for
// client-side navigations) and other non-document fetches must go through
// the SSR handler so the root loader can run and return JSON state data.
const accept = req.headers["accept"] || "";
if (accept.includes("text/html") && !req.url.endsWith(".data")) {
return res
.setHeader("Content-Type", "text/html; charset=utf-8")
.send(shellHtml);
}

// .data requests, API calls, etc. → SSR handler (runs loaders, returns JSON).
return ssrHandler(req, res, next);
});

const requestedPort = parseInt(process.env.PORT || "3000", 10);
const server = app.listen(requestedPort, () => {
// Emit the actual port (important when PORT=0 for auto-assignment).
// Message format matches Reflex's PROD_FRONTEND_LISTENING_REGEX.
const actualPort = server.address().port;
console.log(`[ssr-serve] http://localhost:${actualPort}`);
});
29 changes: 22 additions & 7 deletions reflex/.templates/web/utils/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,7 @@ export const useEventLoop = (
dispatch,
initial_events = () => [],
client_storage = {},
ssrHydrated = false,
) => {
const socket = useRef(null);
const location = useLocation();
Expand Down Expand Up @@ -948,13 +949,27 @@ export const useEventLoop = (
const sentHydrate = useRef(false); // Avoid double-hydrate due to React strict-mode
useEffect(() => {
if (!sentHydrate.current) {
queueEvents(
initial_events(),
socket,
true,
navigate,
() => params.current,
);
if (ssrHydrated) {
// SSR state was applied via StateProvider's initial reducer values.
// Just send hydrate to establish WebSocket session, skip on_load_internal
// since data is already loaded from the server-side render.
queueEvents(
[ReflexEvent(state_name + ".hydrate")],
socket,
true,
navigate,
() => params.current,
);
} else {
// No SSR state — fall back to normal WebSocket hydration.
queueEvents(
initial_events(),
socket,
true,
navigate,
() => params.current,
);
}
sentHydrate.current = true;
}
}, []);
Expand Down
163 changes: 160 additions & 3 deletions reflex/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
from reflex.istate.proxy import StateProxy
from reflex.page import DECORATED_PAGES
from reflex.route import (
extract_route_params,
get_route_args,
replace_brackets_with_keywords,
verify_route_validity,
Expand Down Expand Up @@ -689,6 +690,12 @@ def _add_default_endpoints(self):
health,
methods=["GET"],
)
if get_config().runtime_ssr:
self._api.add_route(
str(constants.Endpoint.SSR_DATA),
ssr_data(self),
methods=["POST"],
)

def _add_optional_endpoints(self):
"""Add optional api endpoints (_upload)."""
Expand Down Expand Up @@ -1461,8 +1468,9 @@ def _submit_work_without_advancing(
progress.advance(task)

# Compile the contexts.
runtime_ssr = get_config().runtime_ssr
compile_results.append(
compiler.compile_contexts(self._state, self.theme),
compiler.compile_contexts(self._state, self.theme, runtime_ssr=runtime_ssr),
)
if self.theme is not None:
# Fix #2992 by removing the top-level appearance prop
Expand All @@ -1471,7 +1479,7 @@ def _submit_work_without_advancing(

# Compile the app root.
compile_results.append(
compiler.compile_app(app_root),
compiler.compile_app(app_root, runtime_ssr=runtime_ssr),
)
progress.advance(task)

Expand All @@ -1484,10 +1492,15 @@ def _submit_work_without_advancing(
with console.timing("Install Frontend Packages"):
self._get_frontend_packages(all_imports)

# Setup the react-router.config.js
# Setup the react-router.config.js and package.json.
frontend_skeleton.update_react_router_config(
prerender_routes=prerender_routes,
)
frontend_skeleton.initialize_package_json()

# Copy SSR scripts when runtime SSR is enabled.
if runtime_ssr:
frontend_skeleton.copy_ssr_scripts()

if is_prod_mode():
# Empty the .web pages directory.
Expand Down Expand Up @@ -1806,6 +1819,21 @@ async def process(
if (path := router_data.get(constants.RouteVar.PATH))
else "404"
).removeprefix("/")
# Server-side extraction of dynamic route params as a fallback.
# When the SPA shell is served for a direct page visit, the client
# may not have route params yet (React Router lazy route discovery
# hasn't completed), so extract them from the pathname to ensure
# on_load handlers receive correct params like slug, id, etc.
if path:
server_params = extract_route_params(
path, router_data[constants.RouteVar.PATH]
)
if server_params:
query = router_data.get(constants.RouteVar.QUERY, {})
for key, value in server_params.items():
if not query.get(key):
query[key] = value
router_data[constants.RouteVar.QUERY] = query
# re-assign only when the value is different
if state.router_data != router_data:
# assignment will recurse into substates and force recalculation of
Expand Down Expand Up @@ -1891,6 +1919,135 @@ async def health(_request: Request) -> JSONResponse:
return JSONResponse(content=health_status, status_code=status_code)


async def _run_ssr_on_load(state: BaseState, load_event: Any, path: str) -> None:
"""Execute a single on_load handler on the ephemeral SSR state.

Args:
state: The ephemeral root state instance.
load_event: The on_load event handler to execute.
path: The URL path (for error logging).
"""
try:
handler_fn = (
load_event.handler if hasattr(load_event, "handler") else load_event
)
# Get the full handler name.
if hasattr(handler_fn, "fn") and hasattr(handler_fn, "state_full_name"):
handler_name = f"{handler_fn.state_full_name}.{handler_fn.fn.__name__}"
else:
handler_name = str(handler_fn)

# Find the target substate and event handler.
target_state, event_handler = state._get_event_handler(handler_name)

# Execute the handler on the target substate.
result = event_handler.fn(target_state)
if asyncio.iscoroutine(result):
result = await result
# For generators (event chains), consume them but ignore returned events.
if inspect.isgenerator(result):
for _ in result:
pass
if inspect.isasyncgen(result):
async for _ in result:
pass
except Exception:
console.warn(f"SSR on_load handler failed for {path}: {traceback.format_exc()}")


def ssr_data(app: App):
"""SSR data loader endpoint.

Creates an ephemeral state, sets route params, runs on_load handlers,
and returns the serialized state for server-side rendering.

Args:
app: The app to get SSR data for.

Returns:
The SSR data handler function.
"""

async def ssr_data_handler(request: Request) -> Response:
"""Handle an SSR data request.

Args:
request: The Starlette request object.

Returns:
Response with the serialized state as JSON.
"""
body = await request.json()

path = body.get("path", "/")
headers = body.get("headers", {})

if not app._state:
return Response(
content='{"state": null}',
media_type="application/json",
)

# Create an ephemeral state instance (no persistent session).
# Use State (root) rather than app._state which may be a subclass
# with inherited vars that can't be set without a parent.
state = State(_reflex_internal_init=True) # pyright: ignore[reportCallIssue]

# Resolve the route pattern from the concrete path.
resolved_route = app.router(path) or "404"

# Extract route params from the path by matching against the route pattern.
# e.g. route="blog/[slug]", path="/blog/hello-world" => {"slug": "hello-world"}
params = extract_route_params(path, resolved_route)

# Build router_data dict (same structure as process() uses).
router_data = {
constants.RouteVar.PATH: "/" + resolved_route.removeprefix("/"),
constants.RouteVar.ORIGIN: path,
constants.RouteVar.QUERY: {
**params,
},
constants.RouteVar.CLIENT_TOKEN: "__ssr__",
constants.RouteVar.SESSION_ID: "__ssr__",
constants.RouteVar.HEADERS: {
"origin": headers.get(
"origin", headers.get("host", "http://localhost")
),
**headers,
},
constants.RouteVar.CLIENT_IP: (
request.client.host if request.client else "0.0.0.0"
),
}

# Set router data on the state — this triggers DynamicRouteVar recomputation.
state.router_data = router_data
state.router = RouterData.from_router_data(router_data)

# Get on_load event handlers for this route.
load_events = app.get_load_events(path)

# Execute each on_load handler directly on the ephemeral state.
for load_event in load_events:
await _run_ssr_on_load(state, load_event, path)

# Serialize the full state tree for the frontend.
full_state = state.dict()

# Use Reflex's json serializer to handle custom types (RouterData, etc.)
json_str = format.json_dumps({"state": full_state})

return Response(
content=json_str,
media_type="application/json",
headers={
"Cache-Control": "no-cache",
},
)

return ssr_data_handler


class _UploadStreamingResponse(StreamingResponse):
"""Streaming response that always releases upload form resources."""

Expand Down
Loading
Loading