#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 API project:
alepha init --api
This generates a project in current directory with a server entry point, a sample controller, and TypeBox schemas.
#Defining Actions
Actions are defined as class properties using $action. Each action becomes an HTTP endpoint.
1import { t } from "alepha"; 2import { $action } from "alepha/server"; 3 4class ProductController { 5 list = $action({ 6 path: "/products", 7 schema: { 8 query: t.object({ 9 page: t.optional(t.integer({ default: 1 })),10 limit: t.optional(t.integer({ default: 10 })),11 }),12 // good practice is to move complex schemas to separate files (api/schemas/*) and import them13 response: t.array(t.object({14 id: t.uuid(),15 name: t.text(),16 price: t.number(),17 })),18 },19 handler: async ({ query }) => {20 return await this.repo.findMany({21 limit: query.limit,22 offset: (query.page - 1) * query.limit,23 });24 },25 });26 27 create = $action({28 method: "POST",29 path: "/products",30 schema: {31 body: t.object({32 name: t.text(),33 price: t.number(),34 }),35 response: t.object({ id: t.uuid(), name: t.text(), price: t.number() }),36 },37 handler: async ({ body }) => {38 return await this.repo.create(body);39 },40 });41}
#URL Generation
$action is a specialized $route where all paths are prefixed with /api by default.
1$action({ path: "/users" }) // GET /api/users2$action({ path: "/users/:id" }) // GET /api/users/:id
The prefix is configurable via the SERVER_API_PREFIX environment variable:
SERVER_API_PREFIX=/v1 # now: GET /v1/users
If path is omitted, the property key is used:
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:
1class App {2 getUser = $action({3 schema: { params: t.object({ id: t.uuid() }) },4 handler: async ({ params }) => { /* ... */ },5 });6 // GET /api/getUser/:id7}
#HTTP Method
The method defaults to GET. If a body schema is provided, it defaults to POST. You can set it explicitly:
1update = $action({ 2 method: "PUT", 3 path: "/products/:id", 4 schema: { 5 params: t.object({ id: t.uuid() }), 6 body: t.object({ name: t.text(), price: t.number() }), 7 response: t.object({ id: t.uuid(), name: t.text(), price: t.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.
#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 TypeBox schemas via the t 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:
1class AdminController { 2 group = "admin"; 3 4 listUsers = $action({ 5 group: this.group, 6 handler: () => { /* ... */ }, 7 }); 8 9 deleteUser = $action({10 group: this.group,11 handler: () => { /* ... */ },12 });13}
#Disabling an Action
The disabled option prevents the route from being registered. Useful for feature flags:
1class App { 2 env = $env(t.object({ 3 ENABLE_BETA: t.boolean({ default: false }), 4 })); 5 6 beta = $action({ 7 disabled: !this.env.ENABLE_BETA, 8 handler: () => "beta feature", 9 });10}
A disabled action throws an error if called via .run().
#Calling Actions Programmatically
Actions can be called directly (no HTTP overhead) or via HTTP:
1// Force 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// Auto (local-first) - calls handler directly if available, otherwise HTTP8const result = await this.list({ query: { page: 1, limit: 10 } });
Calling controllers directly are not recommended for shared libraries. Use
$clientlinks instead, which work across process and network boundaries (see HTTP Links).
#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.
1import { t } from "alepha"; 2import { $sse } from "alepha/server"; 3 4class AiController { 5 chat = $sse({ 6 schema: { 7 body: t.object({ prompt: t.text() }), 8 data: t.object({ token: t.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.
On the client, SSE endpoints are consumed through the same $client proxy as actions:
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 ofresponse - Client receives an async iterable instead of a single value