alepha@docs:~/docs/framework/guides/server$
cat 1-building-an-api.md | pretty
3 min read
Last commit:

#Building an API

Alepha provides type-safe HTTP endpoints through the $action primitive. Actions are class properties that define request schemas, response schemas, and handler logic in a single declaration.

#Quick Start

Scaffold a new project:

bash
alepha init my-api

Every Alepha project ships with src/api/ - a server entry point, a sample controller, and Zod schemas. Building an API-only service? Delete src/web/ and the WebModule line from main.server.ts.

#Defining Actions

Actions are defined as class properties using $action. Each action becomes an HTTP endpoint.

typescript
 1import { z } from "alepha"; 2import { $action } from "alepha/server"; 3  4class ProductController { 5  list = $action({ 6    path: "/products", 7    schema: { 8      query: z.object({ 9        page: z.integer().default(1).optional(),10        limit: z.integer().default(10).optional(),11      }),12      // good practice is to move complex schemas to separate files (api/schemas/*) and import them13      response: z.array(14        z.object({15          id: z.uuid(),16          name: z.text(),17          price: z.number(),18        }),19      ),20    },21    handler: async ({ query }) => {22      return await this.repo.findMany({23        limit: query.limit,24        offset: (query.page - 1) * query.limit,25      });26    },27  });28 29  create = $action({30    method: "POST",31    path: "/products",32    schema: {33      body: z.object({34        name: z.text(),35        price: z.number(),36      }),37      response: z.object({ id: z.uuid(), name: z.text(), price: z.number() }),38    },39    handler: async ({ body }) => {40      return await this.repo.create(body);41    },42  });43}

#URL Generation

$action sits above $route: same pipeline, but all paths are prefixed with /api by default.

typescript
1$action({ path: "/users" }); // GET /api/users2$action({ path: "/users/:id" }); // GET /api/users/:id

The prefix is configurable via the serverApiOptions atom:

typescript
1import { serverApiOptions } from "alepha/server";2 3alepha.store.mut(serverApiOptions, (o) => ({ ...o, prefix: "/v1" }));4// now: GET /v1/users

If path is omitted, the property key is used:

typescript
1class App {2  listUsers = $action({ handler: () => [] });3  // GET /api/listUsers4}

When a params schema is provided and no path is set, path parameters are appended automatically:

typescript
1class App {2  getUser = $action({3    schema: { params: z.object({ id: z.uuid() }) },4    handler: async ({ params }) => {5      /* ... */6    },7  });8  // GET /api/getUser/:id9}

#HTTP Method

The method defaults to GET. If a body schema is provided, it defaults to POST. You can set it explicitly:

typescript
 1update = $action({ 2  method: "PUT", 3  path: "/products/:id", 4  schema: { 5    params: z.object({ id: z.uuid() }), 6    body: z.object({ name: z.text(), price: z.number() }), 7    response: z.object({ id: z.uuid(), name: z.text(), price: z.number() }), 8  }, 9  handler: async ({ params, body }) => {10    return await this.repo.update(params.id, body);11  },12});

Supported methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, CONNECT, TRACE.

#Schema Object

The schema option accepts up to five fields:

Field Purpose
params Path parameters (e.g. /products/:id)
query URL query parameters
body Request body (JSON, text, or multipart)
headers Required request headers
response Response body shape

All fields use Zod schemas via the z helper from alepha. The handler receives fully validated and typed request data.

#Groups

Actions in the same class share a group. The group defaults to the class name. Groups are used for OpenAPI tags and permission namespacing.

Override the group explicitly:

typescript
 1class AdminController { 2  group = "admin"; 3  4  listUsers = $action({ 5    group: this.group, 6    handler: () => { 7      /* ... */ 8    }, 9  });10 11  deleteUser = $action({12    group: this.group,13    handler: () => {14      /* ... */15    },16  });17}

#Disabling an Action

The disabled option prevents the route from being registered. Useful for feature flags:

typescript
 1class App { 2  env = $env( 3    z.object({ 4      ENABLE_BETA: z.boolean().default(false), 5    }), 6  ); 7  8  beta = $action({ 9    disabled: !this.env.ENABLE_BETA,10    handler: () => "beta feature",11  });12}

A disabled action throws an error if called via .run().

#Calling Actions Programmatically

Actions can be called directly (no HTTP overhead) or via HTTP:

typescript
1// Direct local call - runs the handler in-process2const result = await this.list.run({ query: { page: 1, limit: 10 } });3 4// Force HTTP call - sends an actual HTTP request to the server5const response = await this.list.fetch({ query: { page: 1, limit: 10 } });6 7// Calling the action itself is the same as .run() - always local, never HTTP8const same = await this.list({ query: { page: 1, limit: 10 } });

For local-first-then-HTTP dispatch, use $client links, which work across process and network boundaries (see HTTP Links). Calling controllers directly is not recommended for shared libraries.

#Streaming with SSE

For endpoints that stream data progressively (AI chat, progress updates, live feeds), use $sse instead of $action. It returns a text/event-stream response that the client consumes as an async iterable.

typescript
 1import { z } from "alepha"; 2import { $sse } from "alepha/server"; 3  4class AiController { 5  chat = $sse({ 6    schema: { 7      body: z.object({ prompt: z.text() }), 8      data: z.object({ token: z.text() }), 9    },10    handler: async ({ body, emit }) => {11      for await (const token of generateTokens(body.prompt)) {12        emit({ token });13      }14      // stream auto-closes when handler returns15    },16  });17}

The handler receives emit() to push typed events and close() to end the stream early. The stream closes automatically when the handler returns. It also receives signal, an AbortSignal that fires when the client disconnects - check it in any long-running loop, or the handler keeps running for a reader that is gone.

On the client, SSE endpoints are consumed through the same $client proxy as actions:

typescript
1const ctrl = $client<AiController>();2const stream = await ctrl.chat({ body: { prompt: "hello" } });3 4for await (const chunk of stream) {5  console.log(chunk.token);6}

Key differences from $action:

  • Method is always POST
  • Response is text/event-stream (not JSON)
  • Schema uses data (event shape) instead of response
  • Client receives an async iterable instead of a single value