Typesafe Client Generation

Generate a typed TypeScript / React Query client from your route.rs handlers

nextrs can generate a fully-typed TypeScript client — TanStack (React) Query hooks with typed request and response shapes — directly from your route.rs handlers. Rename a field in Rust and the TypeScript call sites stop compiling. The pipeline is OpenAPI-based:

route.rs (#[nextrs::api])  ─codegen→  generated_openapi()
        │                                     │
        │                       cargo run --bin dump-openapi
        ▼                                     ▼
   served at /openapi.json            client/openapi.json
                                              │
                                            orval
                                              ▼
                            src/generated/**  (hooks + types)

Annotate a handler

Handlers stay ordinary Axum handlers — typed extractors in, concrete return types out. Add #[nextrs::api] to the ones you want in the client:

use axum::Json;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

#[derive(Serialize, Deserialize, ToSchema)]
pub struct PingResponse {
    pub message: String,
    pub pong: bool,
}

#[derive(Serialize, Deserialize, ToSchema)]
pub struct PingRequest {
    pub message: String,
}

#[nextrs::api(
    get,
    responses((status = 200, description = "Pong", body = PingResponse)),
)]
pub async fn get() -> Json<PingResponse> {
    Json(PingResponse { message: "pong".into(), pong: true })
}

#[nextrs::api(
    post,
    operation_id = "sendPing",
    responses((status = 200, description = "Echoes the message", body = PingResponse)),
)]
pub async fn post(Json(req): Json<PingRequest>) -> Json<PingResponse> {
    Json(PingResponse { message: req.message, pong: true })
}

#[nextrs::api] is a thin wrapper over #[utoipa::path] that derives the URL from the file's location (app/api/ping/route.rs/api/ping), so the path is never restated and can't drift from the file convention. You write the method, responses(...) (response types aren't inferred from the return type), and optionally operation_id / tag for nicer hook names. The request body is inferred from the Json<T> extractor.

Annotation is opt-in per handler: an un-annotated handler still routes and serves normally — it just doesn't appear in the spec or the generated client.

The spec

The same build-time discovery that wires your routes collects the annotated handlers into a generated_openapi() function. The app serves the document at /openapi.json, and a dump-openapi binary writes the identical spec to client/openapi.json so the client can be generated offline.

Generate the client

The client directory holds the orval config and the committed generated output:

cd site/client
npm install      # first time only
npm run gen      # dump openapi.json from Rust, then run orval
npm run typecheck

Both openapi.json and src/generated/** are committed, so contract changes show up in the diff. Rerun npm run gen whenever an annotated route.rs changes.

Use the hooks

Each annotated handler becomes a hook named from its operation_id — GETs become query hooks, anything with a body becomes a mutation hook:

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useGetApiPing, useSendPing } from "@site/client";

function Ping() {
  const { data } = useGetApiPing();          // GET  /api/ping → typed PingResponse
  const send = useSendPing();                // POST /api/ping → typed PingRequest in

  return (
    <button onClick={() => send.mutate({ data: { message: "hi" } })}>
      {data?.data.message ?? "…"}
    </button>
  );
}

const queryClient = new QueryClient();
export const App = () => (
  <QueryClientProvider client={queryClient}>
    <Ping />
  </QueryClientProvider>
);

The generated client uses the platform fetch (no HTTP-library dependency) and same-origin URLs — the nextrs app serves both the pages and the API, so there's no CORS story to manage.

Why OpenAPI

Direct Rust→TS type generation (ts-rs, specta) only produces types — you'd still hand-write the fetch layer and hooks. Going through OpenAPI lets orval generate the entire client (hooks, types, fetchers), keeps the door open to Swagger UI and non-TypeScript consumers, and the file-convention discovery removes utoipa's usual hand-maintained path list.