TypebaseTypebase
Actions

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

users.ts
users.ts

Flat structure, also fine

users.ts

Grouped by entity, also fine

get.ts
create.ts

Defining an action

An action is built by chaining .input(), .output(), and .handler() on the action builder imported from typebase/_generated/server.ts:

typebase/actions/queries/users.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 becomes input, which is why the handler can read input.id.
  • .output() validates the object returned by this action. Only an object with the declared id, name, age, and email fields reaches the client.
  • .handler() runs the action and returns one response. This handler uses db, the typed Drizzle client, and input, the validated value from .input().
  • ServerError stops the handler and sends a structured error to the client. Here, NOT_FOUND is 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:

typebase/actions/mutations/users.ts
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
CodeHTTP Status
BAD_REQUEST400
UNAUTHORIZED401
FORBIDDEN403
NOT_FOUND404
METHOD_NOT_SUPPORTED405
NOT_ACCEPTABLE406
TIMEOUT408
CONFLICT409
PRECONDITION_FAILED412
PAYLOAD_TOO_LARGE413
UNSUPPORTED_MEDIA_TYPE415
UNPROCESSABLE_CONTENT422
TOO_MANY_REQUESTS429
CLIENT_CLOSED_REQUEST499
INTERNAL_SERVER_ERROR500
NOT_IMPLEMENTED501
BAD_GATEWAY502
SERVICE_UNAVAILABLE503
GATEWAY_TIMEOUT504

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:

ValueAvailable whenWhat it contains
inputThe action uses .input().The input after validation and parsing. Its type is the output type of the input schema.
dbdb/schema.ts exists.A fully typed Drizzle client. Its relational query API comes from db/relations.ts.
authauth.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.
envenv.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.
publisherpublisher.ts exists.A typed publisher. Its event names and payload types come from the events object declared in that file.
reqHeadersEvery 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 valuesAn 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:

typebase/env.ts
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, deleteTodo

Produces 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 codegen

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

On this page