Server
Define tRPC procedures, handle errors, and organize routers.
The server side of @robojs/trpc is where you define your tRPC router and procedures. The plugin provides a custom initTRPC that automatically registers your router with the API endpoint.
Setting up the router
Import initTRPC from @robojs/trpc/server.js and create your tRPC instance:
import { initTRPC } from '@robojs/trpc/server.js'
const t = initTRPC.create()
export const router = t.router
export const procedure = t.procedure
export const appRouter = router({
// procedures go here
})
export type AppRouter = typeof appRouterimport { initTRPC } from '@robojs/trpc/server.js'
const t = initTRPC.create()
export const router = t.router
export const procedure = t.procedure
export const appRouter = router({
// procedures go here
})Always import initTRPC from @robojs/trpc/server.js. Importing from @trpc/server directly bypasses router registration and your API calls will fail.
The AppRouter type export is required — the client imports it for end-to-end type safety.
Procedures
Procedures are the building blocks of your tRPC API. Each procedure is either a query (read) or a mutation (write).
Queries
Queries handle read operations. They map to HTTP GET requests.
import { z } from 'zod'
export const appRouter = router({
getUser: procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
return { id: input.id, name: 'Alice' }
}),
listUsers: procedure.query(() => {
return [{ id: '1', name: 'Alice' }]
})
})import { z } from 'zod'
export const appRouter = router({
getUser: procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
return { id: input.id, name: 'Alice' }
}),
listUsers: procedure.query(() => {
return [{ id: '1', name: 'Alice' }]
})
})Mutations
Mutations handle write operations. They map to HTTP POST requests.
export const appRouter = router({
createUser: procedure
.input(
z.object({
name: z.string(),
email: z.string().email()
})
)
.mutation(({ input }) => {
// Create user in database...
return { id: '2', name: input.name, email: input.email }
})
})export const appRouter = router({
createUser: procedure
.input(
z.object({
name: z.string(),
email: z.string().email()
})
)
.mutation(({ input }) => {
// Create user in database...
return { id: '2', name: input.name, email: input.email }
})
})Input validation
Use Zod schemas with .input() to validate procedure inputs. Invalid input automatically returns a BAD_REQUEST error with validation details.
procedure
.input(
z.object({
name: z.string().min(1).max(100),
age: z.number().int().positive().optional(),
tags: z.array(z.string()).default([])
})
)
.query(({ input }) => {
// input is fully typed and validated
return { name: input.name, age: input.age, tags: input.tags }
})procedure
.input(
z.object({
name: z.string().min(1).max(100),
age: z.number().int().positive().optional(),
tags: z.array(z.string()).default([])
})
)
.query(({ input }) => {
// input is fully typed and validated
return { name: input.name, age: input.age, tags: input.tags }
})Context
Every procedure receives a context object containing the HTTP request and response from @robojs/server. Access it through opts.ctx:
import type { Context } from '@robojs/trpc'
export const appRouter = router({
getProfile: procedure.query(({ ctx }) => {
const authHeader = ctx.req.headers.get('authorization')
const userAgent = ctx.req.headers.get('user-agent')
return { authHeader, userAgent }
})
})
export const appRouter = router({
getProfile: procedure.query(({ ctx }) => {
const authHeader = ctx.req.headers.get('authorization')
const userAgent = ctx.req.headers.get('user-agent')
return { authHeader, userAgent }
})
})The Context interface contains:
Prop
Type
Error handling
Throw TRPCError to return typed errors with HTTP status codes:
import { TRPCError } from '@robojs/trpc'
export const appRouter = router({
deleteUser: procedure
.input(z.object({ id: z.string() }))
.mutation(({ input, ctx }) => {
const auth = ctx.req.headers.get('authorization')
if (!auth) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Missing auth token'
})
}
const user = findUser(input.id)
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `User ${input.id} not found`
})
}
// Delete user...
return { deleted: true }
})
})import { TRPCError } from '@robojs/trpc'
export const appRouter = router({
deleteUser: procedure
.input(z.object({ id: z.string() }))
.mutation(({ input, ctx }) => {
const auth = ctx.req.headers.get('authorization')
if (!auth) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'Missing auth token'
})
}
const user = findUser(input.id)
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `User ${input.id} not found`
})
}
// Delete user...
return { deleted: true }
})
})Common error codes:
| Code | HTTP Status | Use case |
|---|---|---|
BAD_REQUEST | 400 | Invalid input (also thrown automatically by Zod) |
UNAUTHORIZED | 401 | Missing or invalid authentication |
FORBIDDEN | 403 | Insufficient permissions |
NOT_FOUND | 404 | Resource doesn't exist |
CONFLICT | 409 | Resource conflict |
TOO_MANY_REQUESTS | 429 | Rate limit exceeded |
INTERNAL_SERVER_ERROR | 500 | Unexpected server error |
Middleware
Use tRPC middleware to run shared logic before procedures. This is useful for authentication, logging, or adding data to the context.
const isAuthed = t.middleware(({ ctx, next }) => {
const token = ctx.req.headers.get('authorization')
if (!token) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
return next({
ctx: {
...ctx,
userId: parseToken(token)
}
})
})
const protectedProcedure = procedure.use(isAuthed)
export const appRouter = router({
// Public
hello: procedure.query(() => 'Hello'),
// Protected — requires auth
secretData: protectedProcedure.query(({ ctx }) => {
return { userId: ctx.userId, secret: 'classified' }
})
})const isAuthed = t.middleware(({ ctx, next }) => {
const token = ctx.req.headers.get('authorization')
if (!token) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
return next({
ctx: {
...ctx,
userId: parseToken(token)
}
})
})
const protectedProcedure = procedure.use(isAuthed)
export const appRouter = router({
// Public
hello: procedure.query(() => 'Hello'),
// Protected — requires auth
secretData: protectedProcedure.query(({ ctx }) => {
return { userId: ctx.userId, secret: 'classified' }
})
})Splitting routers
As your project grows, split procedures into separate files and merge them:
import { router, procedure } from '../server'
import { z } from 'zod'
export const userRouter = router({
get: procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => ({ id: input.id, name: 'Alice' })),
list: procedure.query(() => [{ id: '1', name: 'Alice' }])
})import { router, procedure } from '../server'
import { z } from 'zod'
export const userRouter = router({
get: procedure
.input(z.object({ id: z.string() }))
.query(({ input }) => ({ id: input.id, name: 'Alice' })),
list: procedure.query(() => [{ id: '1', name: 'Alice' }])
})import { router, procedure } from '../server'
export const postRouter = router({
list: procedure.query(() => [{ id: '1', title: 'Hello' }])
})import { router, procedure } from '../server'
export const postRouter = router({
list: procedure.query(() => [{ id: '1', title: 'Hello' }])
})import { initTRPC } from '@robojs/trpc/server.js'
import { userRouter } from './routers/user'
import { postRouter } from './routers/post'
const t = initTRPC.create()
export const router = t.router
export const procedure = t.procedure
export const appRouter = router({
user: userRouter,
post: postRouter
})
export type AppRouter = typeof appRouterimport { initTRPC } from '@robojs/trpc/server.js'
import { userRouter } from './routers/user'
import { postRouter } from './routers/post'
const t = initTRPC.create()
export const router = t.router
export const procedure = t.procedure
export const appRouter = router({
user: userRouter,
post: postRouter
})The client accesses nested routers with dot notation:
const user = await trpcClient.user.get.query({ id: '1' })
const posts = await trpcClient.post.list.query()const user = await trpcClient.user.get.query({ id: '1' })
const posts = await trpcClient.post.list.query()The start hook
The seeded file at src/robo/start/trpc.ts is critical for router registration:
export default () => import('../../trpc/server.js')export default () => import('../../trpc/server.js')This _start event handler dynamically imports your server file at startup, which triggers initTRPC.create() and t.router(), registering the router before any API requests arrive. Without this file, the tRPC endpoint throws a "Router is not registered" error on every request.
Don't delete src/robo/start/trpc.ts. It's what connects your router to the API endpoint.
