So you have the server running. Now you need to actually do something with it.
In Alepha, we don't write controllers full of decorators, and we don't write route handlers that are just untyped middleware functions. We write Actions.
$action PrimitiveAn $action is a type-safe HTTP endpoint. It bundles everything together:
It uses $route under the hood.
1import { t } from "alepha"; 2import { $action, NotFoundError } from "alepha/server"; 3 4class UserController { 5 users = [{ name: "John Doe", id: 1 }] 6 7 getUser = $action({ 8 path: "/users/:id", 9 schema: {10 params: t.object({ id: t.integer() }),11 response: t.object({12 id: t.integer(),13 name: t.text(),14 }),15 },16 handler: async ({ params }) => {17 const user = this.users.find(u => u.id === params.id);18 if (!user) {19 throw new NotFoundError("User not found");20 }21 return user;22 },23 });24}
The
/apiPrefixAll
$actionpaths are automatically prefixed with/api. Sopath: "/users"becomes/api/users. This keeps your API cleanly separated from pages and static files. Configure it viaSERVER_API_PREFIXenvironment variable if needed.
The schema is where the magic happens. It defines everything about your request and response — and it's not just validation.
Your schema tells Alepha:
void? That's a 204 No Content. Return an object? That's 200 OK.headers schema and Alepha validates them before your handler runs.response schema strips undeclared fields. No accidental data leaks.params — URL Path VariablesWhen your URL has :something, that's a param.
1getUser = $action({ 2 path: "/users/:id", 3 schema: { 4 params: t.object({ 5 id: t.uuid(), // /api/users/550e8400-e29b-41d4-a716-446655440000 6 }), 7 }, 8 handler: async ({ params }) => { 9 // params.id is typed as string, validated as UUID10 },11});
1// Multiple params work too 2getComment = $action({ 3 path: "/posts/:postId/comments/:commentId", 4 schema: { 5 params: t.object({ 6 postId: t.uuid(), 7 commentId: t.uuid(), 8 }), 9 },10 handler: async ({ params }) => {11 // params.postId, params.commentId12 },13});
query — URL Query ParametersThe stuff after the ?. Perfect for filtering, pagination, sorting.
1listUsers = $action({ 2 path: "/users", 3 schema: { 4 query: t.object({ 5 page: t.optional(t.integer({ minimum: 1, default: 1 })), 6 limit: t.optional(t.integer({ minimum: 1, maximum: 100, default: 20 })), 7 search: t.optional(t.text()), 8 role: t.optional(t.enum(["admin", "user", "guest"])), 9 }),10 response: t.object({11 users: t.array(userSchema),12 total: t.integer(),13 }),14 },15 handler: async ({ query }) => {16 // query.page defaults to 1 if not provided17 // query.limit defaults to 2018 // query.search is string | undefined19 // query.role is "admin" | "user" | "guest" | undefined20 return await db.users.findMany({21 skip: (query.page - 1) * query.limit,22 take: query.limit,23 where: { name: { contains: query.search }, role: query.role },24 });25 },26});
body — Request BodyThe payload. When you define a body schema, Alepha:
POST (you can override this)400 Bad Request if validation fails — before your handler even runs 1createUser = $action({ 2 path: "/users", 3 // method: "POST" is implicit when body is defined 4 schema: { 5 body: t.object({ 6 name: t.text({ minLength: 2, maxLength: 100 }), 7 email: t.email(), 8 password: t.text({ minLength: 8 }), 9 role: t.optional(t.enum(["user", "admin"])),10 }),11 response: t.object({12 id: t.uuid(),13 name: t.text(),14 email: t.email(),15 }),16 },17 handler: async ({ body }) => {18 // body is fully validated here19 // body.name has 2-100 chars, body.email is a valid email20 // body.password has at least 8 chars21 // body.role is "user" | "admin" | undefined22 const user = await db.users.create(body);23 return user;24 },25});
headers — Request HeadersSometimes you need to read custom headers.
1webhookHandler = $action({ 2 path: "/webhooks/stripe", 3 method: "POST", 4 schema: { 5 headers: t.object({ 6 "stripe-signature": t.text(), 7 }), 8 body: t.string(), // raw body for signature verification 9 },10 handler: async ({ headers, body }) => {11 const signature = headers["stripe-signature"];12 // verify webhook signature...13 },14});
response — The Most Important SchemaHere's where developers often cut corners. Don't.
The response schema isn't just documentation. It's active serialization.
1getUser = $action({ 2 schema: { 3 params: t.object({ id: t.uuid() }), 4 response: t.object({ 5 id: t.uuid(), 6 name: t.text(), 7 email: t.email(), 8 // notice: no password field 9 }),10 },11 handler: async ({ params }) => {12 const user = await db.users.findById(params.id);13 // user has { id, name, email, password, createdAt, ... }14 return user;15 // Alepha strips everything not in response schema16 // Client receives { id, name, email } only17 },18});
Why this matters:
Security — Accidentally returning password, internalNotes, or deletedAt? The response schema strips them out. You can't leak what you don't declare.
Contract Enforcement — Your API promises a shape. The schema enforces it. If your handler returns something wrong, you'll know immediately.
Client Type Safety — When using $client, the response type is inferred from the schema. Your frontend code gets autocomplete.
OpenAPI Documentation — Swagger UI shows exactly what clients will receive.
Without a response schema, you're flying blind. With one, you have a contract.
Let's be honest: REST can be annoying.
You spend more time debating "should it be /users/:id/posts or /posts?userId=:id" than actually building features. PUT vs PATCH wars. Plural vs singular nouns. It's exhausting.
Alepha makes all that optional. Don't want to think about paths? Don't. Alepha generates them from your property names. Don't want to specify HTTP methods? Don't. Alepha infers them from your schema. You can build a fully functional API without writing a single path or method — and it just works.
Of course, if you want clean RESTful URLs, you can have them. But you're not forced to. Ship first, bikeshed later.
If you don't specify a path, Alepha generates one from the property name:
1class UserController { 2 // path: "/getUser" → GET /api/getUser 3 getUser = $action({ 4 handler: async () => ({ name: "John" }), 5 }); 6 7 // path: "/createUser" → POST /api/createUser (because of body) 8 createUser = $action({ 9 schema: { body: t.object({ name: t.text() }) },10 handler: async ({ body }) => ({ id: "123", name: body.name }),11 });12}
Even better — if you have a params schema, the path auto-appends them:
1class UserController { 2 // Auto-generates path: "/getUser/:id" 3 getUser = $action({ 4 schema: { 5 params: t.object({ id: t.uuid() }), 6 }, 7 handler: async ({ params }) => { 8 return await db.users.findById(params.id); 9 },10 });11}
This convention-over-configuration approach means less boilerplate. But explicit paths are always clearer for complex routes.
Alepha infers the HTTP method:
GETPOST 1// GET /api/users 2listUsers = $action({ 3 path: "/users", 4 handler: async () => [], 5}); 6 7// POST /api/users (implicit because of body) 8createUser = $action({ 9 path: "/users",10 schema: { body: t.object({ name: t.text() }) },11 handler: async ({ body }) => ({ id: "1", name: body.name }),12});13 14// PUT /api/users/:id (explicit override)15updateUser = $action({16 method: "PUT",17 path: "/users/:id",18 schema: {19 params: t.object({ id: t.uuid() }),20 body: t.object({ name: t.text() }),21 },22 handler: async ({ params, body }) => {23 return await db.users.update(params.id, body);24 },25});26 27// DELETE /api/users/:id28deleteUser = $action({29 method: "DELETE",30 path: "/users/:id",31 schema: { params: t.object({ id: t.uuid() }) },32 handler: async ({ params }) => {33 await db.users.delete(params.id);34 },35});
The group property organizes related actions together.
1class OrderController { 2 group = "orders"; 3 4 list = $action({ 5 group: this.group, 6 path: "/orders", 7 handler: async () => [], 8 }); 9 10 create = $action({11 group: this.group,12 path: "/orders",13 schema: { body: orderSchema },14 handler: async ({ body }) => {},15 });16 17 get = $action({18 group: this.group,19 path: "/orders/:id",20 schema: { params: t.object({ id: t.uuid() }) },21 handler: async ({ params }) => {},22 });23}
What group does:
Swagger/OpenAPI Tags — Actions with the same group appear together in the docs. Makes the API explorer navigable.
Permission Names — When using alepha/server/security, permissions are generated as group:action. So orders:create, orders:list, etc.
If you don't set group, it defaults to the class name (OrderController).
descriptionDocument what the action does. Shows up in Swagger:
1createUser = $action({2 description: "Creates a new user account. Sends welcome email.",3 schema: { ... },4 handler: async ({ body }) => {},5});
disabledKill switch. The route won't be registered, but the action can still be called directly via run():
1dangerousAction = $action({2 disabled: this.alepha.isProduction(),3 handler: async () => {4 // Only available in development5 },6});
| Option | Required | Default | Purpose |
|---|---|---|---|
handler |
Yes | — | The function that runs |
path |
No | /${propertyName} |
URL path |
method |
No | GET (or POST if body) |
HTTP method |
schema.params |
No | — | URL path variables |
schema.query |
No | — | URL query parameters |
schema.body |
No | — | Request body |
schema.headers |
No | — | Request headers |
schema.response |
No | — | Response body (serialization!) |
group |
No | Class name | Tag for docs & permissions |
name |
No | Property name | Action identifier |
description |
No | — | Swagger description |
disabled |
No | false |
Disable HTTP registration |