Secure backend logic and APIs

Aha! Builder

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 is an example server function definition. The function is called getCustomer and it accepts requests using the GET HTTP method. The handler returns a Response object, or an object that quacks like one.

import { server, db } from '@aha-app/builder-core';
import { customers } from '../db/schema';
import { eq } from 'drizzle-orm';

server.data('getCustomers', async () => {
return await db.select().from(customers);
});

server.data('getCustomer', async ({ id }) => {
return await db.select().from(customers).where(eq(customers.id, id));
});

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 a 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: any, input) => (prev ? { ...prev, ...input } : prev),
onError: () => toast.error('Failed to update customer'),
refetchOnSuccess: true,
});

Top

Authentication

Authentication is enforced before the handler runs. If the auth check fails, the handler is never called and a 401 response is returned automatically. The default is 'user' (session cookie). Set auth in the options to change it.

auth value

Meaning

'user' (default)

Session cookie required; currentUser() available in handler

'public'

No auth required

{ basic: { realm, username, password } }

Hardcoded basic auth

{ basic: { realm, verify } }

Basic auth with verify callback

(request) => boolean

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
server.api(
'internal',
{
method: 'GET',
path: '/api/internal',
auth: request => request.headers.get('x-token') === process.env.SECRET,
},
async () => {
return { ok: true };
}
);

Top

Roles (RBAC)

When the auth service has roles enabled on the application, every authenticated user has a roles array. Two roles are seeded 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 check runs after the standard authentication check — so it implicitly requires a logged-in user (auth: 'user'). 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']. The role check is only enforced when RBAC is enabled on the application — when RBAC is disabled, 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' };
});

Top

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

application/json

string

text/plain

ArrayBuffer

application/octet-stream

null / undefined

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.

Top

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' };
}
});

Top

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 () => { ... });

Top

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.

Top

Feedback received!

Error submitting feedback, please try again later