Silgi

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).

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.

The createLink function accepts these options:

OptionTypeDefaultDescription
urlstring(required)Server base URL (e.g. "http://localhost:3000")
headersRecord<string, string> or functionundefinedStatic headers or a function that returns headers per request
retrynumber | false0Number of retries on failure
retryDelaynumber | (ctx) => number0Delay between retries in milliseconds
timeoutnumber30000Request timeout in milliseconds
binarybooleanfalseUse 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:

OptionTypeDescription
signalAbortSignalCancel the request
contextRecordCustom 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

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 service

withInterceptors

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 queryOptions and mutationOptions from the client
  • Server — the server-side setup that the client connects to

On this page