Client
Set up the type-safe client with createLink and createClient — including binary mode, interceptors, and type inference.
The Silgi client gives you a fully typed proxy that mirrors your server's router structure. You call procedures like local functions, and TypeScript knows every input and output type.
Setup
Setting up the client takes two steps: create a link (the transport layer) and then create the client (the typed proxy).
Create a link
A link tells the client how to send requests to the server. The built-in link uses ofetch for retries, timeouts, and JSON parsing:
import { } from 'silgi/client/ofetch'
const = ({
: 'http://localhost:3000',
})Relative URLs work too — useful with dev server proxies or same-origin deployments:
import { } from 'silgi/client/ofetch'
const = ({ : '/api' })
// Calls resolve to /api/users/list, /api/health, etc.Create the client
Pass the link to createClient with your router type as the generic parameter:
import { } from 'silgi/client'
import { } from 'silgi/client/ofetch'
import type { } from './server' // export type AppRouter = typeof appRouter
const = ({ : 'http://localhost:3000' })
const = <>()Call procedures
The client mirrors your router structure exactly. Nested routers become nested properties:
// Router structure: { users: { list, create }, health }
const = await client.users.list({ : 10 })
const = await client.users.create({ : 'Alice' })
const = await client.health()Every call has full autocomplete and type checking. If the procedure expects { limit: number }, TypeScript won't let you pass { limit: "ten" }.
Type inference
To get the router type on the client side, export it from your server file:
// server.ts
export const = s.router({
: {
: listUsers,
: createUser,
},
})
export type = typeof Then use it when creating the client:
import type { } from './server'
const = createClient<>(link)You can also use the InferClient utility type, which extracts the client type from any router:
import type { } from 'silgi'
type = <typeof appRouter>
// Client.users.list: (input: { limit?: number }) => Promise<User[]>
// Client.users.create: (input: { name: string }) => Promise<User>In a monorepo, you only need to share the type of the router, not the actual code. Use import type so no server
code leaks into your client bundle.
Link options
The createLink function accepts these options:
| Option | Type | Default | Description |
|---|---|---|---|
url | string | (required) | Server base URL (e.g. "http://localhost:3000") |
headers | Record<string, string> or function | undefined | Static headers or a function that returns headers per request |
retry | number | false | 0 | Number of retries on failure |
retryDelay | number | (ctx) => number | 0 | Delay between retries in milliseconds |
timeout | number | 30000 | Request timeout in milliseconds |
binary | boolean | false | Use MessagePack instead of JSON |
Authentication headers
The most common use for dynamic headers is passing an auth token:
const = createLink({
: 'http://localhost:3000',
: () => ({
: `Bearer ${getToken()}`,
}),
})The header function runs on every request, so it always picks up the latest token. This is important for scenarios like token refresh — when the token changes, the next request automatically uses the new one.
Retry and timeout
const = createLink({
: 'http://localhost:3000',
: 3, // retry up to 3 times on failure
: 1000, // wait 1 second between retries
: 10_000, // 10 second timeout per request
})For exponential backoff, pass a function:
const = createLink({
: 'http://localhost:3000',
: 3,
: () => .(2, .options?.retryCount ?? 0) * 500,
// 500ms, 1000ms, 2000ms
})Binary mode
Set binary: true to use MessagePack instead of JSON. Payloads are smaller and encoding/decoding is faster:
const = createLink({
: 'http://localhost:3000',
: true,
})The client encodes the request body as MessagePack, sets the Content-Type and Accept headers to application/x-msgpack, and the server responds in MessagePack too.
Binary mode is completely transparent to your application code. You still pass and receive regular JavaScript objects. The encoding happens inside the link — you never see MessagePack bytes in your code.
See the MessagePack protocol page for more details on what types are supported.
Interceptors
The link supports ofetch interceptors for fine-grained control over the request lifecycle:
const = createLink({
: 'http://localhost:3000',
({ , }) {
// Runs before every request is sent
.('Sending:', )
},
({ }) {
// Runs after every successful response
.('Received:', .status)
},
({ }) {
// Runs when the request fails (network error, DNS failure, etc.)
.('Request failed:', )
},
({ }) {
// Runs when the server responds with an error status
.('Server error:', .status)
},
})Interceptors are useful for:
- Logging in development
- Token refresh — intercept 401 responses, refresh the token, and retry
- Metrics — measure request timing and success rates
Call options
Each procedure call can receive a second argument with per-call options:
const = new ()
const = await client.users.list({ : 10 }, { : . })
// Cancel the request at any time
.()Available per-call options:
| Option | Type | Description |
|---|---|---|
signal | AbortSignal | Cancel the request |
context | Record | Custom context passed to the link |
Error handling
Server errors are reconstructed as SilgiError instances on the client. You can catch them with try/catch:
import { } from 'silgi'
try {
await client.users.delete({ : 1 })
} catch () {
if ( instanceof ) {
.(.) // "FORBIDDEN"
.(.) // 403
.(.) // { reason: "Not the owner" }
}
}Or use the safe() wrapper to avoid try/catch entirely:
import { } from 'silgi/client'
const { , , , } = await (client.users.delete({ : 1 }))
if () {
.(.code) // "FORBIDDEN"
} else {
.() // the deleted user
}See the Typed Errors page for the complete error handling guide, including the defined flag for distinguishing business errors from unexpected failures.
Server-side client
For SSR, server components, or testing — use createServerClient to get the same typed client interface without any network overhead:
import { } from 'silgi/client/server'
const = (appRouter, {
: () => ({ : getDB() }),
})
// Same API as the HTTP client — but runs in-process
const = await .users.list({ : 10 })
const = await .users.create({ : 'Alice' })The server client compiles the router once and reuses the compiled handlers. Every call runs the full pipeline (guards, wraps, validation) — the only thing skipped is HTTP serialization.
This is especially useful for:
- SSR data fetching — call procedures during server rendering without a network round-trip
- Testing — write integration tests against the real pipeline without starting a server
- Server-to-server — call procedures from background jobs, cron tasks, or other services running in the same process
DynamicLink
A DynamicLink lets you route requests to different links at runtime. The factory function receives the procedure path, input, and call options, and returns the link to use:
import { , } from 'silgi/client'
const = new ((, , ) => {
if ([0] === 'admin') return adminLink
return defaultLink
})
const = <>()This is useful when different parts of your API live on different servers, or when you want to swap between real and mock links based on environment.
mergeClients
Combine multiple Silgi clients into a single object. Each client can point to a different server — the merged result keeps full type safety:
import { } from 'silgi/client'
const = ({
: usersClient,
: billingClient,
})
// client.users.list(...) → hits the users service
// client.billing.charges() → hits the billing servicewithInterceptors
Wrap any link with lifecycle hooks. Unlike ofetch interceptors (which are HTTP-level), these run at the Silgi procedure level and receive the procedure path and input:
import { } from 'silgi/client'
const = (baseLink, {
({ , }) {
.(`-> ${.('/')}`)
},
({ , }) {
.(`<- ${.('/')} (${}ms)`)
},
({ , }) {
reportToSentry()
},
})All three hooks are optional. onError fires on any thrown error — the error still propagates to the caller after the hook runs.
What's next?
- Typed Errors — handle server errors on the client with full type safety
- Protocols — JSON, MessagePack, and devalue encoding
- TanStack Query — generate
queryOptionsandmutationOptionsfrom the client - Server — the server-side setup that the client connects to