Typed SDK Client
The SDK does not ship pre-generated types. You generate types from your own server’s OpenAPI spec, so the types match your exact configuration — including plugin routes.
Your server → GET /api/doc → openapi-typescript → src/generated/api-types.tsInstall
Section titled “Install”bun add @porulle/sdkbun add @tanstack/react-query openapi-react-query # for React hooksbun add -d openapi-typescript # for codegenGenerate types
Section titled “Generate types”Start your server, then run the codegen CLI:
bunx @porulle/sdk generate
# Custom URLbunx @porulle/sdk generate --url http://localhost:4000/api/doc
# Custom output pathbunx @porulle/sdk generate --output src/generated/api-types.tsThis produces a paths type covering every route — core and installed plugins. Commit the generated file and regenerate when you add plugins or change routes.
Create a typed client
Section titled “Create a typed client”import { createClient } from "@porulle/sdk";import type { paths } from "./generated/api-types";
// API key auth (server-to-server, CI, scripts)const client = createClient<paths>({ baseUrl: "http://localhost:4000", auth: { type: "api_key", key: "dev-staff-key" },});
// Bearer token auth (mobile apps, SPAs)const client = createClient<paths>({ baseUrl: "http://localhost:4000", auth: { type: "bearer", token: sessionToken },});Make typed requests
Section titled “Make typed requests”Every path, method, body, query parameter, and response is compile-time validated:
// Catalogconst { data } = await client.GET("/api/catalog/entities", { params: { query: { type: "product", limit: "20" } },});
// Cartconst { data: cart } = await client.POST("/api/carts", { body: { currency: "USD" },});await client.POST("/api/carts/{id}/items", { params: { path: { id: cart.data.id } }, body: { entityId: "entity-uuid", quantity: 2 },});
// Checkoutconst { data: order } = await client.POST("/api/checkout", { body: { cartId: cart.data.id, paymentMethodId: "stripe", currency: "USD", shippingAddress: { line1: "123 Main St", city: "New York", country: "US", firstName: "Jane", lastName: "Doe", }, },});
// Plugin routes are fully typed tooconst { data: points } = await client.GET("/api/loyalty/points/{customerId}", { params: { path: { customerId: "..." } },});If you pass a wrong field name, TypeScript reports it at compile time:
await client.POST("/api/catalog/entities", { body: { typo: "product" } });// ^^^^ TS errorHandle errors
Section titled “Handle errors”Every method returns { data, error, response }. Only one of data or error is populated:
const { data, error } = await client.GET("/api/catalog/entities/{idOrSlug}", { params: { path: { idOrSlug: "nonexistent" } },});
if (error) { console.log(error.error.code); // "NOT_FOUND" console.log(error.error.message); // "Entity not found" return;}
// data is guaranteed non-null hereconsole.log(data.data.id);React hooks
Section titled “React hooks”import { createClient } from "@porulle/sdk";import { createCommerceHooks } from "@porulle/sdk/react";import { QueryClient, QueryClientProvider } from "@tanstack/react-query";import type { paths } from "./generated/api-types";
const client = createClient<paths>({ baseUrl: "", auth: { type: "cookie" } });const commerce = createCommerceHooks(client);const queryClient = new QueryClient();
function App() { return ( <QueryClientProvider client={queryClient}> <ProductList /> </QueryClientProvider> );}Use commerce.useQuery for reads and commerce.useMutation for writes:
function ProductList() { const { data, isLoading } = commerce.useQuery("get", "/api/catalog/entities", { params: { query: { type: "product", limit: "20" } }, });
if (isLoading) return <p>Loading...</p>;
return ( <ul> {data?.data.map((product) => ( <li key={product.id}>{product.slug}</li> ))} </ul> );}
function AddToCartButton({ cartId, entityId }: { cartId: string; entityId: string }) { const addItem = commerce.useMutation("post", "/api/carts/{id}/items");
return ( <button disabled={addItem.isPending} onClick={() => addItem.mutate({ params: { path: { id: cartId } }, body: { entityId, quantity: 1 }, })} > Add to Cart </button> );}Next.js in-process mount
Section titled “Next.js in-process mount”When the Hono app is mounted inside a Next.js catch-all route, use an empty baseUrl so SDK calls route through the same process:
const client = createClient<paths>({ baseUrl: "", auth: { type: "cookie" } });API calls from client components go to /api/... which Next.js routes to your Hono handler. No CORS, no proxy, no separate port.
Untyped convenience wrapper
Section titled “Untyped convenience wrapper”For one-off scripts where you don’t need compile-time validation, createSDK() provides domain namespaces without codegen:
import { createSDK } from "@porulle/sdk";
const sdk = createSDK({ baseUrl: "http://localhost:4000", auth: { type: "api_key", key: "..." } });await sdk.catalog.list({ type: "product" });await sdk.cart.create({ currency: "USD" });Bodies and responses are untyped. For production code, always use createClient<paths>() with generated types.
Regenerate types
Section titled “Regenerate types”After adding, removing, or modifying routes:
bun run dev # start serverbunx @porulle/sdk generate # regenerate typesgit add src/generated/api-types.ts # commitAdd a sdk:generate script to package.json to make this repeatable:
{ "scripts": { "sdk:generate": "bunx @porulle/sdk generate --url http://localhost:4000/api/doc --output src/generated/api-types.ts" }}Related
Section titled “Related”- Next.js guide — in-process mounting and SDK setup
- TanStack Start guide — same pattern for TanStack Start
- REST API Reference — all endpoints and response shapes