Server functions are fundamental to an Aha! Builder application's backend. They run secure, server-side code with access to the database, secrets, and server-only APIs; they require authentication by default; and they support role-based access control and a public API for your application. Elle writes them whenever your application needs to do something on the server.
This reference documents the server function API: defining data and API functions, authentication and roles, return values, and debugging. Read it to understand how your application's backend works, to review what Elle generated, or to ground an AI assistant in the specifics — reference this article with Elle or any LLM so it knows exactly how server functions work in Aha! Builder.
Click any of the following links to skip ahead:
Overview
Server functions allow applications to execute server-side code that has access to the database, secrets and server-side-only APIs. Server functions are secure-by-default: they require authentication to be specified before any user can access them.
Here are two example server function definitions. getCustomers returns every customer and getCustomer returns one by ID. Each handler returns a plain value, or a Response object.
import { server, db } from '@aha-app/builder-core';
import { customers, type Customer } from '../db/schema';
import { eq } from 'drizzle-orm';
type GetCustomerInput = {
id: Customer['id'];
};
export async function getCustomers(): Promise<Customer[]> {
return await db.select().from(customers);
}
export async function getCustomer({ id }: GetCustomerInput): Promise<Customer | null> {
const [customer] = await db.select().from(customers).where(eq(customers.id, id));
return customer ?? null;
}
server.data('getCustomers', getCustomers);
server.data('getCustomer', getCustomer);Server functions can also provide a public API for an application using server.api. API server functions work in the same way as a data function, with the addition of method and path arguments. The handler for an API server function receives an object with the parameters from the path, and an options object including the full request object.
server.api(
'getCustomer',
{
method: 'GET',
path: '/api/customers/:id',
auth: { basic: { realm: 'api', username: 'admin', password: 'secret' } },
},
async ({ id }, { request }) => {
return await db.select().from(customers).where(eq(customers.id, id));
}
);By convention, code for API functions goes in the /api subdirectory, and the file names include .server.ts.
The signature for server.data and server.api is:
type DataFunctionHandler = (...args: any[]) => any;
server.data(name: string, handler: DataFunctionHandler);
server.data(name: string, options, handler: DataFunctionHandler);
type ApiFunctionHandler = (params: {}, context: { request: Request }) => any;
server.api(name: string, options, handler: ServerFunctionHandler);Only the name and handler are required. Options add authentication and routing.
The most common way to call server functions is from client code via the auto-generated @/server module. Each server.data function generates an async wrapper and a React data hook.
import { updateCustomer, getCustomer, useGetCustomer } from '@/server';
import { useServerMutation } from '@aha-app/builder-core';
import { toast } from 'sonner';
// React hook — fetches on mount, re-fetches when args change
const customerQuery = useGetCustomer({ id: 123 });
const { data: customerDetails, loading, error } = customerQuery;
// Async call — for use in event handlers or non-component code
const customerDetails = await getCustomer({ id: 123 });
// Wrap any server function with useServerMutation for optimistic UI updates
const update = useServerMutation(updateCustomer, {
query: customerQuery,
optimistic: (prev, input) =>
prev && prev.id === input.id ? { ...prev, ...input } : prev,
onError: () => toast.error('Failed to update customer'),
refetchOnSuccess: true,
});Typing server function inputs
To get typed wrappers in the generated @/server module, export named handlers and register them by identifier. Derive the input and return types from the Drizzle $inferSelect and $inferInsert types.
import { server, db } from '@aha-app/builder-core';
import { customers, type Customer, type NewCustomer } from '@/db/schema';
type CreateCustomerInput = Pick<NewCustomer, 'name' | 'email'>;
export async function createCustomer(input: CreateCustomerInput): Promise<Customer> {
const [customer] = await db.insert(customers).values(input).returning();
return customer;
}
server.data('createCustomer', createCustomer);Inline or non-exported handlers still work, but the generated client types fall back to unknown. Build these input and return types on the schema types and enums you define in db/schema.ts.
Authentication
Authentication occurs before the handler runs. If the auth check fails, the handler never runs and the framework automatically returns a 401 response. The default is 'user' (session cookie). Set auth in the options to change it. If you enable the governance option "Require authentication," you can use only the user authentication method, which prevents most API endpoints.
|
Meaning |
|
Session cookie required; |
|
No auth required |
|
Hardcoded basic auth |
|
Basic auth with verify callback |
|
Custom auth logic ( |
Examples
// Default: requires logged-in user
server.data('getProfile', async () => {
return { name: 'Alice' };
});
// Public: no auth
server.data(
'healthCheck',
{
auth: 'public',
},
async () => {
return { ok: true };
}
);
// Basic auth with hardcoded credentials
server.api(
'webhook',
{
method: 'POST',
path: '/api/webhook',
auth: { basic: { realm: 'api', username: 'admin', password: 'secret' } },
},
async ({ payload }) => {
return { received: true };
}
);
// Basic auth with verify callback
server.api(
'webhook',
{
method: 'POST',
path: '/api/webhook',
auth: {
basic: {
realm: 'api',
verify: (user, pass) => pass === process.env.API_KEY,
},
},
},
async ({ payload }) => {
return { received: true };
}
);
// Custom auth (API functions only)
server.api(
'internal',
{
method: 'GET',
path: '/api/internal',
auth: request => request.headers.get('x-token') === process.env.SECRET,
},
async () => {
return { ok: true };
}
);Custom auth functions work with server.api only. API functions receive an HTTP Request that the auth function can inspect, and other server function types have no request object. The function must synchronously return true to allow the request. Any other return value rejects it with a 401 response. The request includes only the Authorization, Content-Type, Content-Length, and X-* headers.
Roles (RBAC)
When you enable roles for the auth service on the application, every authenticated user has a roles array. The framework seeds two roles by default: user and admin. Builders can add more from the Builder UI.
Use the top-level role option to require a specific role on a server function. The framework checks this option only with auth: 'user', which is the default, and ignores it for 'public', Basic, and custom authentication. Matching is ANY-of: when an array is supplied, the user passes if they have at least one of the listed roles.
The role option defaults to ['user']. When you enable RBAC on the application, the framework enforces the role check. When you disable RBAC, the check passes automatically.
// Single role
server.data('deleteCustomer', { role: 'admin' }, async ({ id }) => {
await db.delete(customers).where(eq(customers.id, id));
return { ok: true };
});
// Any-of multiple roles
server.data(
'editCustomer',
{ role: ['admin', 'editor'] },
async ({ id, name }) => {
await db.update(customers).set({ name }).where(eq(customers.id, id));
return { ok: true };
}
);A request from a user without any of the required roles receives a 403 with { error: 'Forbidden: missing required role', required: [...] }. An unauthenticated request receives a 401.
Inside the handler, use the typed userHasRole helper from the auto-generated @/auth module to branch on roles.
import { userHasRole } from '@/auth';
server.data('myDashboard', async () => {
if (userHasRole('admin')) {
return { dashboard: 'admin' };
}
return { dashboard: 'standard' };
});Return values
Handlers can return plain values or a standard Response object.
Plain values are auto-wrapped into a Response:
Return type |
Content-Type |
|---|---|
object / array |
|
string |
|
|
|
|
204 No Content |
server.data('getPlants', async () => {
return await db.select().from(plants); // → JSON response
});Response object for full control over status, headers, and body:
import { server, Response } from '@aha-app/builder-core';
server.data('exportCsv', async () => {
const csv = generateCsv();
return new Response(csv, {
headers: { 'Content-Type': 'text/csv' },
});
});
server.api(
'createOrder',
{
method: 'POST',
path: '/api/orders',
},
async ({ items }) => {
const order = await db.insert(orders).values({ items }).returning();
return Response.json(order[0], { status: 201 });
}
);The Response class follows the Web API Response specification, including Response.json(), Response.redirect(), and standard status/header handling.
Debugging
Use console.log() and console.error() inside server functions for debugging. Output appears in the application logs, viewable with the application log viewer.
Use captureError to report caught errors to the application's monitoring service. Without this, errors caught in try/catch blocks are silently swallowed and won't appear in monitoring.
import { server, db, captureError } from '@aha-app/builder-core';
import { customers } from '../db/schema';
import { eq } from 'drizzle-orm';
server.data('updateCustomer', async ({ id, name }) => {
try {
await db.update(customers).set({ name }).where(eq(customers.id, id));
return { success: true };
} catch (error) {
captureError(error as Error);
return { success: false, error: 'Failed to update customer' };
}
});Public applications
All server functions require authentication by default (auth: 'user'). If no auth option is specified, the function will reject unauthenticated requests with a 401 error. For public applications that don't use login, you must explicitly set auth: 'public' on every server function:
server.data('getItems', { auth: 'public' }, async () => { ... });Build it with Elle
Describe the capability you want in plain language, and Elle, the Aha! Builder AI assistant, builds it into your application using the framework above.
New to this? Open Elle, ask what it can do, and try a capability in your application today.