Stop rewriting the same API contract in five different places. @hulla/api lets you define a call once, keep its types attached to the handler, and reuse it everywhere your app needs it.
What is @hulla/api? A tiny API/RPC manager for TypeScript 🚀
- Organize API, server action, database, queue, or local calls in one typed place ✅
- Fix backend changes once, at the route definition, instead of chasing every caller 🛠️
- Works on the client, server, serverless, or anywhere TypeScript runs 💎
- Framework agnostic, with optional Query, SWR, and OpenAPI integrations 🧩
pnpm add @hulla/api
# works also with bun, yarn, npm, deno, etc...Optional integrations:
pnpm add @hulla/api-query # @tanstack/query
pnpm add @hulla/api-swr # swr
pnpm add @hulla/api-openapi # openapi/swagger -> api (gen)Start with an API instance and a procedure:
import{init}from'@hulla/api'constapi=init()constping=api.procedure.handler(()=>'pong')ping.call()// "pong"Add schemas when you want runtime parsing and inferred TypeScript:
import{z}from'zod'constdouble=api.procedure.input(z.number()).output(z.number()).handler(({ input })=>input*2)double.call(21)// 42double.call(null)// TS error: expected type 'number', got 'null'// Runtime error through zod validationGroup named procedures with routers:
constusers=api.router('users').define(({ procedure })=>({all: procedure.handler(()=>[{id: 1,name: 'Samuel'},{id: 2,name: 'Jane'},]),byId: procedure.input(z.number()).handler(({ input })=>({id: input,name: 'Samuel',})),}))users.all.call()users.byId.call(1)users.all.key.root// "users/all"users.byId.key.full(1)// ["users/byId", 1]Note
The route logic stays out of your transport layer, so the same procedure can wrap fetch, a database query, a server action, a queue job, or a local or a server function.
Middleware is declared once and selected where it applies:
typeSession={userId: string}typeAdminPermissions={canDeleteUsers: boolean}asyncfunctiongetSession(): Promise<Session>{returnfetch('/api/session').then((res)=>res.json())}asyncfunctiongetAdminPermissions(): Promise<AdminPermissions>{constpermissions=awaitfetch('/api/admin-permissions').then((res)=>res.json()asPromise<AdminPermissions>)if(!permissions.canDeleteUsers){thrownewError('Admin access required')}returnpermissions}constapi=init({middleware: {session: getSession,admin: getAdminPermissions,},})constaccount=api.router('account').use('session').define(({ procedure })=>({me: procedure.handler(async({ getContext })=>{const{ session }=awaitgetContext()return{id: session.userId}}),deleteUser: procedure.use('admin').input(z.string()).handler(async({ input, getContext })=>{const{ session, admin }=awaitgetContext()return{deletedBy: session.userId,canDelete: admin.canDeleteUsers,id: input,}}),}))Routers pass their middleware to every procedure. Procedure-level .use(...) adds to that selection, and duplicate middleware keys are deduped.
In most apps, create one configured instance and export it as api:
// src/api.tsimport{init}from'@hulla/api'import{query}from'@hulla/api-query'typeSession={userId: string}asyncfunctiongetSession(): Promise<Session>{returnfetch('/api/session').then((res)=>res.json())}exportconstapi=init({middleware: {session: getSession,},plugins: [query()],})Then import that configured instance wherever routes live:
// src/routes/users.tsimport{z}from'zod'import{api}from'../api'exportconstusers=api.router('users').use('session').define(({ procedure })=>({list: procedure.handler(async({ getContext })=>{const{ session }=awaitgetContext()returnfetch(`/api/users?viewer=${session.userId}`).then((res)=>res.json())}),byId: procedure.input(z.string().uuid()).output(z.object({id: z.string(),name: z.string(),})).handler(async({ input })=>{constresponse=awaitfetch(`/api/users/${input}`)returnresponse.json()}),}))The finalized procedure is the runtime value:
constuser=awaitusers.byId.call('2f2f0f0c-0f0f-4f0f-8f0f-0f0f0f0f0f0f')users.byId.key.root// "users/byId"users.byId.key.full('user_123')// ["users/byId", "user_123"]users.byId.query.options('user_123')// TanStack Query optionsUse API-level .use(...) when a whole group of routes shares middleware.
init() declares the middleware that exists, and .use(...) returns a scoped
API instance where that middleware runs for every procedure and router:
// src/api.tsimport{init}from'@hulla/api'typeSession={userId: string}typeAdminPermissions={canDeleteUsers: boolean}asyncfunctiongetSession(): Promise<Session>{returnfetch('/api/session').then((res)=>res.json())}asyncfunctiongetAdminPermissions(): Promise<AdminPermissions>{constpermissions=awaitfetch('/api/admin-permissions').then((res)=>res.json()asPromise<AdminPermissions>)if(!permissions.canDeleteUsers){thrownewError('Admin access required')}returnpermissions}constapi=init({middleware: {session: getSession,admin: getAdminPermissions,},})exportconstpublicApi=apiexportconstprotectedApi=api.use('session')exportconstadminApi=api.use('session','admin')Protected routes can now define procedures directly. The procedure passed into
the router keeps the API-level selection, so getContext() is typed from the
selected middleware:
// src/routes/account.tsimport{z}from'zod'import{protectedApi}from'../api'exportconstaccount=protectedApi.router('account').define(({ procedure })=>({me: procedure.handler(async({ getContext })=>{const{ session }=awaitgetContext()return{id: session.userId}}),rename: procedure.input(z.string().min(1)).handler(async({ input, getContext })=>{const{ session }=awaitgetContext()return{id: session.userId,name: input}}),}))And public routes stay visibly public:
// src/routes/health.tsimport{publicApi}from'../api'exportconsthealth=publicApi.router('health').define(({ procedure })=>({check: procedure.handler(()=>({ok: true})),}))The same pattern works for standalone procedures:
// src/actions/viewer.tsimport{protectedApi}from'../api'exportconstviewer=protectedApi.procedure.handler(({ getContext })=>getContext())Router-level and procedure-level .use(...) still work on scoped APIs, so you
can add more middleware for a specific router or procedure. publicApi,
protectedApi, and adminApi are just project-level names. @hulla/api only cares
about the selected middleware keys, so you can use authed, internal,
tenant, or whatever matches your app.
@hulla/api-query adds query.options(...) and mutation.options(...) helpers designed for TanStack Query.
import{init}from'@hulla/api'import{query}from'@hulla/api-query'import{z}from'zod'constapi=init({plugins: [query()],})constusers=api.router('users').define(({ procedure })=>({all: procedure.handler(()=>fetch('/api/users').then((res)=>res.json())),byId: procedure.input(z.number()).handler(({ input })=>fetch(`/api/users/${input}`).then((res)=>res.json())),}))constlistOptions=users.all.query.options()constboundUserOptions=users.byId.query.options(1)constlazyUserOptions=users.byId.query.options()constmutationOptions=users.byId.mutation.options()// useQuery(listOptions)// useQuery(boundUserOptions)// lazyUserOptions.queryFn(1)// useMutation(mutationOptions)For input procedures, calling .options(input) binds the input into the query key and query function. Calling .options() returns the root key and a function that accepts the input later.
@hulla/api-swr exposes query.options(...) and mutation.options(...) helpers as SWR tuples.
import{init}from'@hulla/api'import{swr}from'@hulla/api-swr'import{z}from'zod'constapi=init({plugins: [swr()],})constusers=api.router('users').define(({ procedure })=>({byId: procedure.input(z.number()).handler(({ input })=>fetch(`/api/users/${input}`).then((res)=>res.json())),}))const[key,fetcher]=users.byId.query.options(1)const[mutationKey,mutate]=users.byId.mutation.options()// useSWR(key, fetcher)// useSWRMutation(mutationKey, mutate)@hulla/api-openapi generates @hulla/api client factories from OpenAPI documents.
bunx @hulla/api-openapi ./openapi.json --output ./src/api.generated.tsYou can derive procedure names from paths instead of operationId:
bunx @hulla/api-openapi ./openapi.json --output ./src/generated-api --names pathThe generated client accepts your transport function:
import{createOpenAPIClient}from'./api.generated'constclient=createOpenAPIClient((request)=>{returnfetch(request.path,{method: request.method,body: request.body===undefined ? undefined : JSON.stringify(request.body),}).then((res)=>res.json())})constuser=awaitclient.users.getUsersId.call({params: {id: 'user_123'},})By default, output schemas parse the resolved handler value, so async handlers work with plain schemas:
constuser=api.procedure.output(z.string()).handler(async()=>'Samuel')awaituser.call()// "Samuel"If you want output schemas to validate the exact unawaited return value instead, set output to raw.
constapi=init({settings: {output: 'raw',},})- Install dependencies with
bun install - Run checks with
bun run lint,bun run fmt,bun run test, andbun run build
Plugins are injected automatically by default. You can make a plugin opt-in, select it on a router or procedure, or alias exposed members.
constapi=init({plugins: [query()],settings: {plugins: {query: {inject: 'opt-in',aliases: {procedure: {query: 'rq',},},},},},})constusers=api.router('users').plugin('query').define(({ procedure })=>({byId: procedure.input(z.number()).handler(({ input })=>input),}))users.byId.rq.options(1)