Client Generation: Step by Step
Turn typed Rust path, query, body, response, and error contracts into fetch functions and React Query hooks
This walkthrough starts with one Rust endpoint, then adds the inputs and error
cases that demonstrate end-to-end inference. The generated client requires no
any, handwritten interface, relative generated import, or module shim.
1. Write a typed Rust endpoint
Create app/api/todos/[id]/route.rs:
use axum::{extract::{Path, Query}, http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
#[derive(Deserialize, IntoParams)]
pub struct TodoQuery {
pub neighbors: Option<bool>,
}
#[derive(Serialize, ToSchema)]
pub struct Todo {
pub id: u64,
pub title: String,
pub done: bool,
}
#[nextrs::api(
get,
responses(
(status = 200, description = "The todo", body = Todo),
(status = 404, description = "Not found"),
),
)]
pub async fn get(
Path(id): Path<u64>,
Query(query): Query<TodoQuery>,
) -> Result<Json<Todo>, StatusCode> {
find_todo(id, query.neighbors.unwrap_or(false))
.await
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
The [id] directory and Path<u64> define the path argument. Query<TodoQuery>
defines query options. The response declarations produce a status union. The
default operation ID is derived from method and path; set operation_id in the
annotation when you want a shorter public name.
This remains an ordinary Axum handler. The attribute adds it to the OpenAPI document; it does not create a second RPC runtime.
2. Generate from the app root
Install the unified CLI once:
cargo install cargo-nextrs
cargo nextrs client generate
nextrs client generate is equivalent. Generation refreshes the Rust
contract, produces both fetch and React Query surfaces, runs the application
build, and emits JavaScript and .d.ts files for the linked client package.
Never run npm install in .nextrs/client. The root project owns dependencies
and links this generated workspace.
3. Call the framework-independent client
import { getApiTodosById } from "@mysite/client";
const response = await getApiTodosById(42, { neighbors: true });
if (response.status === 200) {
console.log(response.data.title);
} else {
console.log("Todo was not found");
}
The path argument must be a number; neighbors must be a boolean when
present; and the 200 branch carries Todo. TypeScript rejects invalid calls
at the call site.
The package root uses the platform fetch API and has no React dependency in
its public surface. Use it in browser modules, event handlers, or another UI
framework.
4. Use React Query integration
React-specific APIs live at the explicit subpath:
import {
getGetApiTodosByIdQueryOptions,
useGetApiTodosById,
} from "@mysite/client/react-query";
export function TodoDetail({ id }: { id: number }) {
const todo = useGetApiTodosById(id, { neighbors: true });
if (todo.isPending) return <p>Loading…</p>;
if (todo.data?.status !== 200) return <p>Not found</p>;
return <p>{todo.data.data.title}</p>;
}
const options = getGetApiTodosByIdQueryOptions(42, { neighbors: true });
Query data is inferred from the fetch function. There is no need to write a response generic or annotate callback data.
5. Add a typed request body and mutation
Add a patch handler to the same route.rs:
#[derive(Deserialize, ToSchema)]
pub struct UpdateTodoRequest {
pub done: bool,
}
#[nextrs::api(
patch,
operation_id = "updateTodo",
request_body = UpdateTodoRequest,
responses((status = 200, description = "Updated todo", body = Todo)),
)]
pub async fn patch(
Path(id): Path<u64>,
Json(body): Json<UpdateTodoRequest>,
) -> Json<Todo> {
Json(update_todo(id, body.done).await)
}
Regenerate and use the direct client:
import { updateTodo } from "@mysite/client";
await updateTodo(42, { done: true });
Or let the generated mutation infer its variables:
import { useUpdateTodo } from "@mysite/client/react-query";
const update = useUpdateTodo({
mutation: {
onSuccess: (_response, variables) => {
console.log(variables.id, variables.data.done);
},
},
});
update.mutate({ id: 42, data: { done: true } });
variables.id and variables.data are inferred from Path<u64> and
UpdateTodoRequest. Do not annotate either as any.
6. Watch a Rust change reach TypeScript
Rename Todo.title to Todo.label, then regenerate:
cargo nextrs client generate
Every stale .title use now fails at the exact consumer. That is the intended
feedback loop: one Rust-owned contract drives fetch calls, query results,
mutation variables, status unions, and editor completion.
7. Use imports from any nested file
The generated package is a linked root dependency with explicit exports. A
new file such as app/todos/[id]/details/page.tsx uses the same stable imports:
import { getApiTodosById } from "@mysite/client";
import { useUpdateTodo } from "@mysite/client/react-query";
TypeScript reads .nextrs/client/dist/index.d.ts and
dist/react-query.d.ts; JavaScript and the browser bundler read the matching
.js files. Resolution does not depend on a page already existing, a nextrs
runtime alias, or user-authored tsconfig.paths.
The rule to remember
After changing a #[nextrs::api] contract, regenerate from the application
root:
cargo nextrs client generate
For package ownership, troubleshooting, and contract mapping, read the Generated TypeScript Client reference.