Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
2272cc0
wip
benedikt-bartscher Mar 2, 2026
f120b60
Merge remote-tracking branch 'upstream/main' into ssr
benedikt-bartscher Mar 12, 2026
0606e2b
fix: add fixture to clean state subclasses after tests
benedikt-bartscher Mar 13, 2026
32bd4a7
make ssr deps optional
benedikt-bartscher Mar 13, 2026
1158c3c
add integration test
benedikt-bartscher Mar 13, 2026
0287832
fix typing
benedikt-bartscher Mar 13, 2026
263d3eb
codespell
benedikt-bartscher Mar 14, 2026
fe877a6
fix
benedikt-bartscher Mar 14, 2026
9369532
fix hashes
benedikt-bartscher Mar 14, 2026
2feca15
fix js formatting
benedikt-bartscher Mar 15, 2026
0274b8e
cache this
benedikt-bartscher Mar 15, 2026
d3d9c74
Merge remote-tracking branch 'upstream' into ssr
benedikt-bartscher Mar 18, 2026
257ed79
wip
benedikt-bartscher Apr 25, 2026
8411139
plugin based ssr
benedikt-bartscher Jul 18, 2026
8da2d30
Merge remote-tracking branch 'upstream/main' into ssr-plugin
benedikt-bartscher Jul 18, 2026
ec14b54
isr wip
benedikt-bartscher Jul 18, 2026
1b735e5
test
benedikt-bartscher Jul 18, 2026
0f7d74b
ruff
benedikt-bartscher Jul 18, 2026
9096d34
Merge remote-tracking branch 'upstream/main' into ssr-plugin
benedikt-bartscher Oct 2, 2026
9d62492
Merge remote-tracking branch 'upstream/main' into ssr-plugin
benedikt-bartscher Oct 20, 2026
9c86da6
Fix SSR load inputs, catch-all params and ISR lock ownership
benedikt-bartscher Oct 21, 2026
0893414
Hoist ISR and SSR imports, document handlers and add news fragments
benedikt-bartscher Oct 21, 2026
a51c82e
Merge remote-tracking branch 'upstream/main' into ssr-plugin
benedikt-bartscher Oct 21, 2026
c87e5ff
Merge remote-tracking branch 'upstream/main' into ssr-plugin
benedikt-bartscher Oct 25, 2026
e763577
Install express for the ISR render service
benedikt-bartscher Oct 25, 2026
01f9053
Decode SSR route params like React Router
benedikt-bartscher Oct 25, 2026
1c6edaa
Cache ISR pages by raw path and drop the unimplemented bot-only mode
benedikt-bartscher Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions news/+ssr-isr.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add experimental, default-off server-side rendering: setting the new `ssr_mode` config option (`rx.SsrMode`) adds an `/_ssr_data` endpoint that runs a page's `on_load` handlers, so server-rendered pages contain their data. The new `reflex.isr` page server (`granian --factory reflex.isr:create_isr_app`) caches those pages in Redis with stale-while-revalidate, and `reflex.isr.revalidate_path`, `reflex.isr.revalidate_tag` or `POST /_isr/revalidate` invalidate them on demand. Pages are cached per path without the query string; a page's `on_load` fills in query-dependent content after hydration.
1 change: 1 addition & 0 deletions packages/reflex-base/news/+ssr-isr.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add the experimental, default-off `ssr_mode` config option with the frontend templates that render pages on the server, and the `isr_render_url`, `isr_revalidate` and `isr_build_id` options that configure incremental static regeneration, which renders and caches pages per path without the query string.
109 changes: 109 additions & 0 deletions packages/reflex-base/src/reflex_base/.templates/web/isr-render.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* ISR render tier for Reflex.
*
* A small HTTP service that renders the react-router **server bundle** to HTML
* for a given route, so the Python ISR page-server (reflex.isr.HttpRenderer)
* can cache and serve it. This is the one component that must run JavaScript;
* it is deployed as its own service (off the request hot path, scale-to-zero
* friendly) rather than in every frontend pod.
*
* Contract (matches reflex.isr.HttpRenderer):
* POST / { "path": "/blog/hello" }
* 200 -> { "html": "<!DOCTYPE html>…", "revalidate"?: number, "tags"?: string[] }
* non-200 -> ISR treats it as a render failure (serves stale/shell)
*
* Requirements:
* - An SSR build of the app (react-router build with `ssr: true`) producing
* `build/server/index.js`. The app root `loader()` fetches page state from
* the Python `/_ssr_data` endpoint, so this service needs network access to
* the backend (set SSR_DATA / the backend URL in the app env).
* - Node deps `react-router` and `express`, which the SSR build installs.
*
* Run: node isr-render.mjs (listens on $PORT, default 8600)
*
* Optional per-page cache hints: the app may set response headers
* `X-Reflex-Revalidate: <seconds>` and `X-Reflex-Tags: a,b,c`
* which are forwarded to the ISR cache; otherwise Python applies its defaults.
*/
import { createRequestHandler } from "react-router";
import express from "express";
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const __dirname =
import.meta.dirname ?? dirname(fileURLToPath(import.meta.url));

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

const app = express();
app.use(express.json({ limit: "1mb" }));

// Health endpoint for k8s probes.
app.get("/_health", (_req, res) => res.status(200).send("OK"));

app.post("/", async (req, res) => {
const path =
typeof req.body?.path === "string" && req.body.path ? req.body.path : "/";

try {
// Render the target route through react-router as a normal GET document
// request. The app's loader runs here and pulls state from /_ssr_data.
const url = new URL(path, "http://reflex-isr-render.local");
const request = new Request(url, {
method: "GET",
headers: {
"User-Agent": "ReflexISRRenderer/1.0",
Accept: "text/html",
// Forward the caller's cookies so authenticated/personalized loaders
// resolve the same way they would for the original visitor, if the
// page-server chooses to pass them along.
...(req.body?.cookie ? { Cookie: String(req.body.cookie) } : {}),
},
});

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

if (!response.ok || !html) {
return res
.status(502)
.json({ error: "render_failed", status: response.status });
}

const revalidate = headerNumber(response, "x-reflex-revalidate");
const tags = headerList(response, "x-reflex-tags");

return res.json({
html,
...(revalidate != null ? { revalidate } : {}),
...(tags.length ? { tags } : {}),
});
} catch (err) {
console.error("[isr-render] render error for", path, err);
return res.status(500).json({ error: String(err) });
}
});

function headerNumber(response, name) {
const raw = response.headers.get(name);
if (raw == null) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}

function headerList(response, name) {
const raw = response.headers.get(name);
return raw
? raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: [];
}

const port = parseInt(process.env.PORT || "8600", 10);
app.listen(port, () => {
console.log(`[isr-render] listening on http://localhost:${port}`);
});
81 changes: 75 additions & 6 deletions packages/reflex-base/src/reflex_base/compiler/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from collections.abc import Iterable, Mapping
from typing import TYPE_CHECKING, Any, Literal

from reflex_base import constants
from reflex_base import constants, ssr
from reflex_base.utils import memo_paths
from reflex_base.utils.format import json_dumps
from reflex_base.vars.base import VarData
Expand Down Expand Up @@ -177,6 +177,50 @@ def app_root_template(
f' "{lib_path}": {lib_alias},' for lib_alias, lib_path in window_libraries
])

if ssr.is_enabled():
ssr_imports = (
'import { useLoaderData } from "react-router";\n'
'import { getBackendURL } from "$/utils/state";\n'
'import { SSRStateContext } from "$/utils/context";\n'
'import env from "$/env.json";'
)
ssr_loader = """
export async function loader({ request }) {
// Fetch on_load-hydrated state from the Python /_ssr_data endpoint so the
// server-rendered HTML already contains real data.
try {
const url = new URL(request.url);
const res = await fetch(getBackendURL(env.SSR_DATA), {
method: "POST",
headers: {
"Content-Type": "application/json",
...(request.headers.get("cookie") ? { Cookie: request.headers.get("cookie") } : {}),
},
body: JSON.stringify({
path: url.pathname,
search: url.search,
headers: Object.fromEntries(request.headers),
}),
});
if (res.ok) return res.json();
} catch (e) {
console.error("SSR data fetch failed:", e);
}
return { state: null };
}
"""
layout_body = (
"const loaderData = useLoaderData();\n"
" const ssrState = loaderData?.state ?? null;\n"
" return jsx(AppLayout, {}, "
"jsx(SSRStateContext.Provider, {value: ssrState}, "
"jsx(ReflexProviders, {}, children)));"
)
else:
ssr_imports = ""
ssr_loader = ""
layout_body = "return jsx(AppLayout, {}, jsx(ReflexProviders, {}, children));"

window_imports_effect = f"""useEffect(() => {{
// Make contexts and state objects available globally for dynamic eval'd components
window.__reflex = {{
Expand Down Expand Up @@ -222,11 +266,12 @@ def app_root_template(
import {{ ThemeProvider }} from '$/utils/react-theme';
import {{ Layout as AppLayout }} from './_document';
import {{ Outlet }} from 'react-router';
{ssr_imports}
{import_window_libraries}
{lazy_imports_setup}

{custom_code_str}

{ssr_loader}
function ReflexProviders({{children}}) {{
{window_imports_effect}

Expand All @@ -242,7 +287,7 @@ def app_root_template(
}}

export function Layout({{children}}) {{
return jsx(AppLayout, {{}}, jsx(ReflexProviders, {{}}, children));
{layout_body}
}}

// Used by entry.client.js when mount_target is configured: skips the document
Expand Down Expand Up @@ -306,6 +351,7 @@ def context_template(
Returns:
Rendered context file content as string.
"""
ssr_enabled = ssr.is_enabled()
initial_state = initial_state or {}
if initial_state_json is None:
initial_state_json = json_dumps(initial_state)
Expand Down Expand Up @@ -375,6 +421,26 @@ def context_template(
"""
)

if ssr_enabled:
# The store may start from SSR state, so the first hydrate needs it all.
initial_state_hashes = None
ssr_context_str = "export const SSRStateContext = createContext(null);"
ssr_state_hook = "const ssrState = useContext(SSRStateContext);"
# Server-rendered substates take precedence over the compiled defaults.
client_initial_state = (
"ssrState ? Object.fromEntries(Object.entries(initialState).map("
"([name, value]) => [name, ssrState[name] ?? value])) : initialState"
)
server_state_store = (
"ssrState ? { ...serverStateStore, getSnapshot: "
"(name) => ssrState[name] ?? initialState[name] } : serverStateStore"
)
else:
ssr_context_str = ""
ssr_state_hook = ""
client_initial_state = "initialState"
server_state_store = "serverStateStore"

disable_owner_stacks_str = (
r"""
// Disable React dev-build owner-stack capture: the per-element Error()
Expand Down Expand Up @@ -403,7 +469,7 @@ def context_template(
else ""
)

return rf"""import {"React, " if disable_react_owner_stacks else ""}{{ useContext, useMemo, useState, createElement, useEffect }} from "react"
return rf"""import {"React, " if disable_react_owner_stacks else ""}{{ {"createContext, " if ssr_enabled else ""}useContext, useMemo, useState, createElement, useEffect }} from "react"
import {{ applyDelta, ReflexEvent, hydrateClientStorage, useEventLoop, refs }} from "$/utils/state"
import {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext, StateStoreContext, registerApp, eventLoop }} from "$/utils/context-registry"
import {{ jsx }} from "@emotion/react";
Expand All @@ -414,6 +480,7 @@ def context_template(
export const stateSchema = {json.dumps(state_schema)}

export const defaultColorMode = {default_color_mode}
{ssr_context_str}
export const clientStorage = {"{}" if client_storage is None else json.dumps(client_storage)}

{state_str}
Expand Down Expand Up @@ -555,7 +622,8 @@ def context_template(
}}

function ClientStateProvider({{ children }}) {{
const [store] = useState(() => createStateStore(initialState));
{ssr_state_hook}
const [store] = useState(() => createStateStore({client_initial_state}));
useEffect(() => store.mergeInitialState(initialState), [store, initialState]);
return createElement(StateStoreProviders, {{ children, store }});
}}
Expand All @@ -569,7 +637,8 @@ def context_template(
}};

function ServerStateProvider({{ children }}) {{
return createElement(StateStoreProviders, {{ children, store: serverStateStore }});
{ssr_state_hook}
return createElement(StateStoreProviders, {{ children, store: {server_state_store} }});
}}

export const StateProvider =
Expand Down
21 changes: 21 additions & 0 deletions packages/reflex-base/src/reflex_base/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,11 @@ class BaseConfig:
plugins: List of plugins to use in the app.
disable_plugins: List of plugin types to disable in the app.
transport: The transport method for client-server communication.
ssr_mode: Server-side rendering mode ("off" or "always").
isr_render_url: ISR render-tier URL; when set, enables ISR page serving.
Pages are rendered and cached per path, without the query string.
isr_revalidate: Default seconds until an ISR-cached page goes stale.
isr_build_id: Identifier that rotates the ISR cache on each deploy.
"""

app_name: str
Expand Down Expand Up @@ -285,6 +290,22 @@ class BaseConfig:

transport: Literal["websocket", "polling"] = "websocket"

# Server-side rendering mode: "off" (default) or "always".
ssr_mode: constants.SsrMode = constants.SsrMode.OFF

# ISR render-tier URL. When set, the ISR page-server regenerates pages by
# POSTing paths here; when None, ISR is disabled. Pages are rendered and
# cached per path without the query string; the page's on_load fills in
# query-dependent content after hydration.
isr_render_url: str | None = None

# Default seconds until an ISR-cached page is considered stale.
isr_revalidate: int = 60

# Identifier that rotates the ISR cache on each deploy. Defaults at runtime
# to the REFLEX_BUILD_ID env var (see reflex.isr.get_build_id).
isr_build_id: str | None = None

# Whether to skip plugin checks.
_skip_plugins_checks: bool = dataclasses.field(default=False, repr=False)

Expand Down
2 changes: 2 additions & 0 deletions packages/reflex-base/src/reflex_base/constants/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
Reflex,
ReflexHostingCLI,
RunningMode,
SsrMode,
Templates,
)
from .compiler import (
Expand Down Expand Up @@ -140,6 +141,7 @@
"RouteVar",
"RunningMode",
"SocketEvent",
"SsrMode",
"StateManagerMode",
"Templates",
"UvLock",
Expand Down
15 changes: 13 additions & 2 deletions packages/reflex-base/src/reflex_base/constants/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,8 @@ class ReactRouter(Javascript):
DEV_FRONTEND_LISTENING_REGEX = r"Local:[\s]+"

# Regex to pattern the route path in the config file
# INFO Accepting connections at http://localhost:3000
PROD_FRONTEND_LISTENING_REGEX = r"Accepting connections at[\s]+"
# Matches output from sirv ("Accepting connections at") or ssr-serve ("[ssr-serve]").
PROD_FRONTEND_LISTENING_REGEX = r"(?:Accepting connections at|\[ssr-serve\])[\s]+"

FRONTEND_LISTENING_REGEX = (
rf"(?:{DEV_FRONTEND_LISTENING_REGEX}|{PROD_FRONTEND_LISTENING_REGEX})(.*)"
Expand Down Expand Up @@ -210,6 +210,17 @@ class Env(str, Enum):
PROD = "prod"


class SsrMode(str, Enum):
"""Server-side rendering modes.

OFF: No SSR — static SPA (default, same as before).
ALWAYS: SSR for all users — better FCP/LCP, higher server load.
"""

OFF = "off"
ALWAYS = "always"


class RunningMode(str, Enum):
"""The running modes."""

Expand Down
1 change: 1 addition & 0 deletions packages/reflex-base/src/reflex_base/constants/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class Endpoint(Enum):
AUTH_CODESPACE = "auth-codespace"
HEALTH = "_health"
ALL_ROUTES = "_all_routes"
SSR_DATA = "_ssr_data"

def __str__(self) -> str:
"""Get the string representation of the endpoint.
Expand Down
5 changes: 5 additions & 0 deletions packages/reflex-base/src/reflex_base/constants/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@ def DEPENDENCIES(cls) -> dict[str, str]:
"babel-plugin-react-compiler": "1.0.0",
}

# Imported by the ISR render service (isr-render.mjs); installed by SSR builds.
ISR_RENDER_DEPENDENCIES = {
"express": "5.3.0",
}

# Force specific transitive npm deps to a single resolved version when
# needed. Prefer a `DEV_DEPENDENCIES`/`DEPENDENCIES` pin when the package is
# one we depend on directly: a top-level pin already satisfies and dedupes
Expand Down
Loading
Loading