React Pages & Server Props (Preview)
Roadmap preview: page.tsx in the app tree, with the React Query cache warmed by the server before your bundle runs
Status: implemented, pre-release. Everything on this page runs in the nextrs repo today — the runnable
examples/react-todoscrate is exactly this code. APIs may still shift before a release. The typed-client pipeline it builds on is documented at Typesafe Client Generation.
The idea
nextrs will let you drop page.tsx files into the app/ tree next to page.rs and page.html:
app/
├── layout.rs # Rust layouts wrap React pages like any other page
└── todos/
├── page.tsx # React page — discovered and routed by the same codegen
└── props.rs # optional: Rust warms your React Query cache
.tsx pages are client-rendered by default. The server streams the layout shell and a script tag; your component renders in the browser and talks to the backend through the generated typed hooks. One Rust binary serves the APIs, the Rust pages, and the React pages. There is no Node server and no JS runtime inside the binary — that's a permanent constraint, not a phase. If a page needs request-time server rendering, that's what page.rs is for.
The interesting part is what replaces server-side rendering's data story.
The waterfall, and props.rs
A client-rendered page normally pays: stream shell → download bundle → mount React → hook fires a fetch → round-trip back to the server that just streamed the shell. The server had the data the whole time.
props.rs is a Rust file beside your page that runs per request, calls the same handler that serves the API endpoint, and injects the result into the streamed HTML — keyed exactly the way the generated client keys its queries:
// app/todos/props.rs
include!(concat!(env!("OUT_DIR"), "/nextrs_seeds.rs"));
pub async fn props(req: http::Request<axum::body::Body>) -> nextrs::QuerySeed {
nextrs::QuerySeed::new()
// A plain typed function call (no HTTP): runs the GET /api/todos
// handler and pairs the result with its canonical query key.
.seed(get_api_todos(
api_todos::TodosFilter { status: Some("open".into()) },
req.extensions(),
))
.await
}
The get_api_todos companion (and the api_todos module alias that makes the filter type reachable) is generated by the build from the #[nextrs::api] annotation on the handler — seedable handlers are GETs taking at most one Query<T> extractor and returning Json<...>.
By the time your bundle executes, the JSON is already in the DOM, loaded into the React Query cache before mount.
What the page looks like
The payoff: the component has no idea any of this happened. It's vanilla React Query — except the data is just there on first paint:
// app/todos/page.tsx
import { useQueryClient } from "@tanstack/react-query";
import { useGetTodos, useAddTodo, getGetTodosQueryKey } from "@site/client";
export default function Todos() {
const queryClient = useQueryClient();
// Warmed from the stream: defined on first render, no spinner, no mount
// fetch. Goes stale and refetches like any query afterward.
const { data: todos, refetch, isFetching } = useGetTodos({ status: "open" });
const addTodo = useAddTodo({
mutation: {
onSuccess: () => {
// Prefix invalidation refetches every /api/todos variant — including
// the server-seeded entry, because the seed used the same canonical
// key the hooks use.
queryClient.invalidateQueries({ queryKey: getGetTodosQueryKey() });
},
},
});
return (
<section>
<button onClick={() => refetch()} disabled={isFetching}>Refresh</button>
<ul>{todos?.data.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
<button onClick={() => addTodo.mutate({ data: { title: "ship nextrs" } })}>
Add
</button>
</section>
);
}
Three properties worth noticing:
- Seeding is a pure progressive enhancement. Delete
props.rsand this file works unchanged — it just fetches on mount instead of rendering instantly. - Mutations invalidate seeded data. The seed lives under the same
[url, params]key the hooks use, so yourinvalidateQueriescall refreshes streamed data and fetched data alike. - Refetching, staleness, optimistic updates are untouched. The seed is an ordinary cache entry; everything React Query does applies to it.
Thin handlers, and why seeds go through them
nextrs's conventions are deliberately just the adapter layer — route.rs, page.rs, middleware.rs, and props.rs all translate between the web and your domain logic, which lives wherever you keep it (a lib crate, a core module). Handlers stay thin:
// app/api/todos/route.rs — adapter only: extract, delegate, map
#[nextrs::api(get, responses((status = 200, body = Vec<Todo>)))]
pub async fn get(Query(f): Query<TodosFilter>) -> Json<Vec<Todo>> {
Json(core::todos::list(f.into()).await)
}
props.rs runs on the server, so it could call core::todos::list directly. It calls the handler instead, on purpose: the seed is a cache entry keyed by URL — it impersonates a response from GET /api/todos, and the client will refetch that endpoint later and overwrite it. The wire shape (the DTO mapping, serde casing, the response envelope) belongs to the HTTP adapter, so producing a cache entry for that endpoint has to go through the adapter — or risk drifting from it and flickering from seed-shape to handler-shape on the first refetch. With a thin handler, calling it costs exactly one DTO mapping more than calling the service, and that mapping is the part the seed can't safely skip.
Server data that isn't endpoint-shaped — session user, feature flags, a precomputed view model — impersonates nothing, so it skips the HTTP adapter entirely: that's the design's second mode, plain typed initial props (usePageProps<T>()), with the TypeScript type generated from the Rust struct through the same OpenAPI pipeline. One rule, two lanes: data that belongs to an endpoint goes through the endpoint's adapter; data that belongs to the page goes through the page's.
End-to-end type safety
The same property the typed client has, extended to seeds and props: the Rust structs derive ToSchema, the schema flows into the OpenAPI document, and orval generates the TypeScript. Rename a field in Rust and the .tsx stops compiling.
Where this is headed
- Phase 1 — client-rendered
page.tsx: discovery, routing, and bundling (Rust toolchain via swc) wired intocargo build; a dev watcher that rebuilds bundles in milliseconds without restarting the server. - Phase 2 —
props.rsas shown above: typed initial props, then React Query cache seeding. - Phase 3 — build-time prerendering:
loading.tsxskeletons and static.tsxpages rendered to HTML during the build (Node at build time only) and hydrated in the browser.
Follow along or argue with us: github.com/drewhirschi/nextrs.