Actions
Define your server-side logic with type-safe actions.
Last updated on
Actions are the server-side functions that power your API. They are how your client communicates with your backend, reading data, creating records, sending emails, or anything else your app needs to do.
Under the hood, actions are built on top of oRPC, a type-safe RPC framework. Typebase provides a focused API for defining actions and re-exports oRPC client plugins when you need retries, deduplication, or batching. Custom server plugins are not supported yet.
Folder structure
The npx typebase-io-cli init command scaffolds a typebase/actions/ folder with queries/ and mutations/ subfolders. This is just a suggested convention. The only requirement is that your action files live somewhere inside the typebase/actions/ folder. You're free to organize them however you want:
Suggested convention
Flat structure, also fine
Grouped by entity, also fine
Defining an action
An action is built by chaining .input(), .output(), and .handler() on the action builder imported from typebase/_generated/server.ts:
import { ServerError } from 'typebase-io/server';
import { z } from 'zod';
import { action } from '../../_generated/server.ts';
export const getUser = action
.input(
z.object({
id: z.number(),
})
)
.output(
z.object({
id: z.number(),
name: z.string(),
age: z.number(),
email: z.email(),
})
)
.handler(async ({ db, input }) => {
const user = await db.query.users.findFirst({ where: { id: input.id } });
if (!user) {
throw new ServerError('NOT_FOUND');
}
return {
id: user.id,
name: user.name,
age: user.age,
email: user.email,
};
});Let's break that down:
.input()validates the{ id }sent by the client. The validated value becomesinput, which is why the handler can readinput.id..output()validates the object returned by this action. Only an object with the declaredid,name,age, andemailfields reaches the client..handler()runs the action and returns one response. This handler usesdb, the typed Drizzle client, andinput, the validated value from.input().ServerErrorstops the handler and sends a structured error to the client. Here,NOT_FOUNDis returned when no user matches the requested id.
This example only needs db and input. Depending on the action and your project setup, a handler can receive other values too. See Handler context for the complete list and the exact conditions for each value.
This example uses .handler(), which returns once. To send multiple events over SSE, use .stream() instead. Streaming actions receive the
applicable action context plus stream-specific values for cancellation and resuming. See Streaming for the full API.
Input and output validation
Typebase accepts any library that implements the Standard Schema spec. This means you're not locked into any single validation library; use whichever one you prefer.
Here's the same action written with different validation libraries:
Zod
import { z } from 'zod';
export const getUser = action
.input(z.object({ id: z.number() }))
.output(z.object({ id: z.number(), name: z.string() }))
.handler(async ({ db, input }) => {
// ...
});Valibot
import * as v from 'valibot';
export const getUser = action
.input(v.object({ id: v.number() }))
.output(v.object({ id: v.number(), name: v.string() }))
.handler(async ({ db, input }) => {
// ...
});ArkType
import { type } from 'arktype';
export const getUser = action
.input(type({ id: 'number' }))
.output(type({ id: 'number', name: 'string' }))
.handler(async ({ db, input }) => {
// ...
});Mutations
There's no technical difference between a "query" and a "mutation" in Typebase. They're both actions, and the distinction is purely organizational. A mutation is just an action that writes data:
import { ServerError } from 'typebase-io/server';
import { z } from 'zod';
import { action } from '../../_generated/server.ts';
import { users } from '../../db/schema.ts';
export const createUser = action
.input(
z.object({
name: z.string(),
age: z.number(),
email: z.email(),
})
)
.output(
z.object({
id: z.number(),
name: z.string(),
age: z.number(),
email: z.email(),
})
)
.handler(async ({ db, input }) => {
const result = await db
.insert(users)
.values({
name: input.name,
age: input.age,
email: input.email,
})
.returning();
const user = result.at(0);
if (!user) {
throw new ServerError('INTERNAL_SERVER_ERROR');
}
return {
id: user.id,
name: user.name,
age: user.age,
email: user.email,
};
});Error handling
Throw a ServerError to return a structured error to the client. It maps to standard HTTP status codes:
import { ServerError } from 'typebase-io/server';
throw new ServerError('NOT_FOUND');
throw new ServerError('BAD_REQUEST', { message: 'Email is already taken' });
throw new ServerError('UNAUTHORIZED');
throw new ServerError('FORBIDDEN');
throw new ServerError('INTERNAL_SERVER_ERROR');All error codes
| Code | HTTP Status |
|---|---|
BAD_REQUEST | 400 |
UNAUTHORIZED | 401 |
FORBIDDEN | 403 |
NOT_FOUND | 404 |
METHOD_NOT_SUPPORTED | 405 |
NOT_ACCEPTABLE | 406 |
TIMEOUT | 408 |
CONFLICT | 409 |
PRECONDITION_FAILED | 412 |
PAYLOAD_TOO_LARGE | 413 |
UNSUPPORTED_MEDIA_TYPE | 415 |
UNPROCESSABLE_CONTENT | 422 |
TOO_MANY_REQUESTS | 429 |
CLIENT_CLOSED_REQUEST | 499 |
INTERNAL_SERVER_ERROR | 500 |
NOT_IMPLEMENTED | 501 |
BAD_GATEWAY | 502 |
SERVICE_UNAVAILABLE | 503 |
GATEWAY_TIMEOUT | 504 |
Handler context
The parameter object passed to .handler() is inferred from the action chain and your project structure. Typebase only exposes values that apply to the current action and project:
| Value | Available when | What it contains |
|---|---|---|
input | The action uses .input(). | The input after validation and parsing. Its type is the output type of the input schema. |
db | db/schema.ts exists. | A fully typed Drizzle client. Its relational query API comes from db/relations.ts. |
auth | auth.ts exists. | The better-auth server instance. It does not authenticate the request automatically; use it with reqHeaders, usually from middleware, to read the session. |
env | env.ts, db/schema.ts, or auth.ts exists. | The parsed values declared in env.ts, plus DATABASE_URL for database projects and BETTER_AUTH_SECRET for auth projects. The automatic keys are required strings unless you override their schemas in env.ts. |
publisher | publisher.ts exists. | A typed publisher. Its event names and payload types come from the events object declared in that file. |
reqHeaders | Every action. The value itself can be undefined. | The incoming request headers as a Headers object. Guard against undefined before passing them to APIs that require headers. |
| Middleware values | An earlier .use() middleware returns them. | Any properties returned by middleware, fully typed and available to later middleware and the final handler. For example, authentication middleware can add a user value. input is added only for the final handler, not for middleware. |
A handler can combine whichever values it needs:
export const getProfile = action.input(z.object({ id: z.number() })).handler(async ({ db, input, auth, env, reqHeaders }) => {
if (!reqHeaders) {
throw new ServerError('UNAUTHORIZED');
}
const session = await auth.api.getSession({ headers: reqHeaders });
if (!session) throw new ServerError('UNAUTHORIZED');
await notify(env.RESEND_API_KEY, session.user.email);
return db.query.users.findFirst({ where: { id: input.id } });
});An action that needs no context is equally valid:
export const ping = action.handler(async () => {
return { ok: true };
});Environment variables
Declare what your server needs in typebase/env.ts, and read it from the action context as env:
import { defineEnv } from 'typebase-io/server';
import { z } from 'zod';
export const env = defineEnv({
RESEND_API_KEY: z.string().min(1),
});export const sendEmail = action.input(z.object({ to: z.email(), subject: z.string() })).handler(async ({ env, input }) => {
const apiKey = env.RESEND_API_KEY; // string, validated at boot
// ...
});process.env still works anywhere inside typebase/ for anything you haven't declared. In development, values come from your local .env file; in production, from whatever you've configured on your deployment provider. Manage them with npx typebase-io-cli env, and see Environment Variables for the full reference.
How routing works
Typebase automatically builds a router from your actions/ folder structure. Each file becomes a namespace, and each exported action becomes an endpoint.
More specifically, Typebase only includes exported values that resolve to a Typebase/oRPC procedure. You can export helpers, constants, and shared types from the same file without them showing up on the client router.
For example, this folder structure:
typebase/actions/
queries/
todos.ts → exports: getMany, getOne
users.ts → exports: getById
mutations/
todos.ts → exports: create, toggle, deleteTodoProduces this router:
router.queries.todos.getMany();
router.queries.todos.getOne({ id: 1 });
router.queries.users.getById({ id: 1 });
router.mutations.todos.create({ value: 'Buy milk' });
router.mutations.todos.toggle({ id: 1 });
router.mutations.todos.deleteTodo({ id: 1 });This same structure is what your client uses to call actions from the frontend. See the Next.js Guide for client setup.
Regenerating types
Run codegen whenever you create, delete, or rename a file inside actions/, or when you add or remove auth.ts, env.ts, publisher.ts, db/schema.ts, or db/relations.ts:
npx typebase-io-cli codegenThis updates _generated/server.ts with the new router structure so your client stays in sync. npx typebase-io-cli deploy runs this for you on every deploy, so explicit codegen runs are only needed when you're iterating without redeploying.
Next steps
- Learn how to add middleware to your actions for authentication and shared logic.
- Push more than one response with streaming actions, and connect mutations to streams with a publisher.
- Read the Client overview to see how to call your actions from the frontend.