LogoRobo.js

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:

src/trpc/server.ts
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 appRouter
src/trpc/server.js
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
})

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.

src/trpc/server.ts
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' }]
	})
})
src/trpc/server.js
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.

src/trpc/server.ts
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 }
		})
})
src/trpc/server.js
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:

src/trpc/server.ts
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 }
	})
})
src/trpc/server.js

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:

src/trpc/server.ts
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 }
		})
})
src/trpc/server.js
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:

CodeHTTP StatusUse case
BAD_REQUEST400Invalid input (also thrown automatically by Zod)
UNAUTHORIZED401Missing or invalid authentication
FORBIDDEN403Insufficient permissions
NOT_FOUND404Resource doesn't exist
CONFLICT409Resource conflict
TOO_MANY_REQUESTS429Rate limit exceeded
INTERNAL_SERVER_ERROR500Unexpected server error

Middleware

Use tRPC middleware to run shared logic before procedures. This is useful for authentication, logging, or adding data to the context.

src/trpc/server.ts
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' }
	})
})
src/trpc/server.js
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:

src/trpc/routers/user.ts
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' }])
})
src/trpc/routers/user.js
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' }])
})
src/trpc/routers/post.ts
import { router, procedure } from '../server'

export const postRouter = router({
	list: procedure.query(() => [{ id: '1', title: 'Hello' }])
})
src/trpc/routers/post.js
import { router, procedure } from '../server'

export const postRouter = router({
	list: procedure.query(() => [{ id: '1', title: 'Hello' }])
})
src/trpc/server.ts
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 appRouter
src/trpc/server.js
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
})

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:

src/robo/start/trpc.ts
export default () => import('../../trpc/server.js')
src/robo/start/trpc.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.

Next Steps

On this page