Server-first Quickstart
Learn how to use rest-rpc with server-first approach
While rest-rpc is primarily designed for contract-first development, it can also be used server-first. In server-first development, the client is derived from the server implementation automatically.
This is useful when:
- You don’t want to create a separate package for the API contract
- Server implementation should be the single source of truth
- You want the least amount of boilerplate possible
- You are more used to thinking in terms of server routes than API contracts
This guide mirrors the Quickstart guide, but with a server-first approach.
Install packages
Add the core package, a schema library, and the adapter for the server framework you want to use.
Define and Implement the server route
Write server handlers using any Fetch or Node HTTP adapter compatible framework.
Call it from the client
Create a typed fetch client or TanStack Query helpers from the same contract.
Install Packages
This guide uses Zod and Node.js HTTP by default. The validation library can be replaced with any library implementing the Standard Schema or a built-in type-only schema.
pnpm add @rest-rpc/core @rest-rpc/node zodnpm install @rest-rpc/core @rest-rpc/node zodyarn add @rest-rpc/core @rest-rpc/node zodbun add @rest-rpc/core @rest-rpc/node zodThe server-first approach is currently implemented to @rest-rpc/node and @rest-rpc/fetch adapters.
These adapters can generally be used inside any framework that is built on Node.js HTTP or Fetch API.
For the TanStack Query client example, also install:
pnpm add @rest-rpc/tanstack-query @tanstack/react-query
Define routes on the server
import { createServer } from "node:http";
import { route, createRouteHandler } from "@rest-rpc/node";
import { z } from "zod";
export const routes = {
todos: {
create: route
.post("/todos")
.body(z.object({ title: z.string().min(1) }))
.handler(({ title }) => ({
status: 201,
body: { id: crypto.randomUUID(), title, completed: false },
})),
},
};
const handle = createRouteHandler(routes);
const server = createServer(async (request, response) => {
const { matched } = await handle(request, response);
if (!matched) {
response.writeHead(404).end("Not found");
}
});
server.listen(3000);import { route, createRouteHandler } from "@rest-rpc/fetch";
import { z } from "zod";
export const routes = {
todos: {
create: route
.post("/todos")
.body(z.object({ title: z.string().min(1) }))
.handler(async ({ title }) => ({
status: 201 as const,
body: { id: crypto.randomUUID(), title, completed: false },
})),
},
};
const handle = createRouteHandler(routes);
export default {
async fetch(request: Request) {
const result = await handle(request);
return result.matched
? result.response
: new Response("Not found", { status: 404 as const });
},
};Create a Client
import { initClient } from "@rest-rpc/core";
import type { routes } from "./server";
const client = initClient<typeof routes>({
baseUrl: "https://api.example.com",
});
const response = await client.$post("/todos", {
body: { title: "Ship v1" },
});
const todo = response.body;import { createTanstackQueryHelpers } from "@rest-rpc/tanstack-query";
import { useMutation } from "@tanstack/react-query";
import type { routes } from "./server";
const tq = createTanstackQueryHelpers<typeof routes>({
baseUrl: "http://localhost:3000",
});
const createTodo = useMutation(
tq.$post("/todos").mutationOptions({
onSuccess(response) {
console.log(response.body.id);
},
}),
);
createTodo.mutate({
title: "Ship v1",
});Next Steps
- Server-first Server covers request and response inference.
- Server-first Client covers grouped inputs, special request encodings, and TanStack Query differences.
- HTTP Requests and HTTP Responses cover behavior shared with contract-first routes.
- Node HTTP and Fetch Runtime cover adapter setup and options.
- Contract-first Quickstart covers the contract-first approach if that feels more natural to you.