React Pages & Server Prefetch
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 discovers React pages and their optional server prefetch from the app/
tree:
app/
├── layout.tsx # shared React layout
└── todos/
├── page.tsx # React page — discovered and routed by the same codegen
└── prefetch.rs # optional: Rust warms your React Query cache
.tsx pages are client-rendered. The server sends the React shell and script;
your component renders in the browser and talks to the Rust backend through
generated typed hooks. One Rust binary serves the frontend assets and APIs.
There is no Node server or JavaScript runtime inside the binary.
The interesting part is what replaces server-side rendering's data story.
The waterfall, and prefetch.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.
prefetch.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/prefetch.rs
include!(concat!(env!("OUT_DIR"), "/nextrs_seeds.rs"));
pub async fn prefetch(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 returning Json<...> (or Result<Json<...>, E>) whose extractors are at most one Path, at most one Query<T>, plus any Extension<T> / WaitUntil args. Extension state (your DB handle installed with .layer(Extension(ctx))) and WaitUntil are pulled from the request extensions automatically during prefetch — a handler needing app context stays fully seedable. If an Extension value is missing at prefetch time, the entry seeds nothing and the page falls back to fetch-on-mount. (State<T> is not supported — hold shared context in an Extension layer instead.)
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 "@my-app/client/react-query";
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
prefetch.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 Rust conventions are deliberately just the adapter layer — route.rs,
middleware.rs, and prefetch.rs translate between the web and domain logic,
which lives wherever you keep it. Handlers stay thin:
// app/api/todos/route.rs — adapter only: extract, delegate, map
#[nextrs::api]
pub async fn get(Query(f): Query<TodosFilter>) -> Json<Vec<Todo>> {
Json(core::todos::list(f.into()).await)
}
prefetch.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.
The supported seed contract is endpoint-shaped on purpose. For session data, feature flags, or a page-specific view model, expose the typed endpoint whose wire representation the browser will later refetch, then seed that same endpoint. This keeps first-paint data and subsequent client data on one typed path.
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.
What ships today
- Client-rendered
page.tsx— discovery, routing, and bundling run incargo build. The bundler is embedded Rolldown, gated behind thetsxcargo feature. Root JavaScript dependencies still supply React and the generated-client toolchain; there is no separate application frontend server. prefetch.rsReact Query cache seeding — exactly as shown above: the server streams seed entries into the HTML and the client loads them into the cache before mount.loading.tsxskeletons — a loading component mounts immediately while the page bundle loads.
Generated hooks come from @my-app/client/react-query; direct fetch functions
come from @my-app/client. Both are normal package exports backed by emitted
JavaScript and declarations.
Still on the roadmap: build-time prerendering — static .tsx pages 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.