Every Aha! Builder application stands on the same foundation: a React front end, file-system routing, server functions, a PostgreSQL database, and built-in authentication. Aha! Builder wires these together before you send your first prompt. Elle follows the conventions below every time it adds a page, a table, or a piece of backend logic to your application.
This reference documents the framework itself: the directory structure, the routing rules, the @/ path alias, and the exports every application draws on. Read it to find your way around your application's code, 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 the framework fits together in Aha! Builder.
Click any of the following links to skip ahead:
Directory structure
application.css Theme variables (CSS custom properties for shadcn).
Generated with default colors — edit to customize the theme.
components/ Reusable UI components
Header.tsx
Sidebar.tsx
lib/ Utility functions
hooks/ Custom React hooks
data/ Server functions (*.server.ts)
customers.server.ts Server functions for customers
billing.server.ts Server functions for billing
pages/ File-system routing (see below)
layout.tsx Root layout (wraps all pages)
index.tsx / (root route)
[slug].tsx Dynamic segment → /:slug
db/
schema.ts Drizzle ORM table definitions
migrations/
001_create_customers.sql
002_create_orders.sql
cron/ Scheduled functions
api/ Public HTTP endpointsThe @/ path alias resolves to the project root, so import Header from '@/components/Header' works from anywhere in your application.
Two directories hold code that runs on a schedule or answers outside callers: cron/ holds your scheduled and recurring tasks, and api/ holds the public HTTP endpoints your server functions expose.
Routing
The framework derives routes from pages/ at build time, so you never register a route by hand.
pages/index.tsx→/pages/customers/index.tsx→/customerspages/customers/[id].tsx→/customers/:id(dynamic segment)pages/customers/[id]/orders/[order_id].tsx→/customers/:id/orders/:order_idpages/settings/profile.tsx→/settings/profile(non-index sub-route)
Follow these conventions:
Prefer
directory/index.tsxover top-level files.Write dynamic segments in bracket syntax:
[id].tsx→:id.Add a
layout.tsxin any directory to wrap every route in that subtree. Layouts nest.
Authentication
Your application handles user sign-in and access control through three exports from @aha-app/builder-core:
<Authenticate>— Wraps its children and redirects to login when no one has signed in.currentUser()— Returns aUser(id,email,firstName,lastName,avatarUrl) ornull. Works synchronously in both client and server code.redirectToLogin(returnTo?)andredirectToLogout(returnTo?)— Send the browser through the login or logout flow.
Do not call currentUser() in the same component that renders <Authenticate>. The parent component runs in full regardless of authentication state, so read the user in a child component inside <Authenticate> instead.
User management (server functions only)
Use the AhaUsers class for administrator-level create, read, update, and delete operations on Aha!-managed authentication users. These users live in the Aha! platform database, distinct from the local users table that currentUser() reads. AhaUsers works in server functions only.
import { server, AhaUsers } from '@aha-app/builder-core';
server.data('listUsers', async () => {
return AhaUsers.list({ page: 1, perPage: 50 });
});
server.data('createUser', async ({ email, firstName, lastName }) => {
return AhaUsers.create({ email, firstName, lastName });
});The AhaUser instances that list, findById, and create return each carry update, delete, and revokeSessions methods.
Navigation
Import Link, useParams, useNavigate, and useLocation from @aha-app/builder-core to move between your application's interactive interfaces without a full page load.
import {
Link,
useParams,
useNavigate,
useLocation,
} from '@aha-app/builder-core';
// Link component for declarative navigation
<Link to='/customers/42'>View Customer</Link>;
// Access dynamic route params (e.g. from /customers/[id].tsx)
const { id } = useParams();
// Programmatic navigation
const navigate = useNavigate();
const path = useLocation();Page title
Set the browser tab title from any page or component with usePageTitle. It supports dynamic titles, so the tab updates whenever the argument changes.
import { usePageTitle } from '@aha-app/builder-core';
export default function CustomersPage() {
usePageTitle('Customers | My App');
return <div>...</div>;
}Server functions
Server functions are .server.ts files in the data/ directory. They use server.data, and your client code calls them over remote procedure call (RPC). Every server function requires authentication by default. If your application does not use <Authenticate> or implement login, you must set auth: 'public' on your server functions, or they will return 401 errors.
Server runtime capabilities
Server functions run in a sandboxed V8 runtime, not Node.js and not a browser. Standard ECMAScript APIs are available, along with this web-platform subset:
Networking:
fetch,Headers,Request,Response,FormDataData and encoding:
URL,URLSearchParams,Blob,File,TextEncoder,TextDecoder,atob,btoaStreams:
ReadableStream, with basic reading, async iteration, cancellation, andtee()Cancellation and events:
AbortController,AbortSignal(abort,any,throwIfAborted),Event,EventTarget,DOMExceptionScheduling and timing:
queueMicrotask,performance.now,performance.timeOriginCrypto:
crypto.getRandomValues,crypto.randomUUID, SHA-1/256/384/512 digests, HMAC sign and verify, and PBKDF2deriveBits. Other Web Crypto algorithms and methods will not run.Framework globals:
console, andprocess.envfor application secrets
Server functions do not support Node.js built-ins (fs, path, http, Buffer, and similar), filesystem or shell access, timers (setTimeout, setInterval), workers, sockets, browser DOM APIs, localStorage, structuredClone, compression streams, writable and transform streams, or the rest of Web Crypto.
The framework resolves and bundles npm packages, but bundling does not make a Node.js or browser-only package runtime-compatible. Before you add a package for server code, prefer one that is pure JavaScript and relies only on the supported APIs above. A package that pulls in Node.js built-ins or browser DOM APIs will fail at runtime — switch to a compatible alternative when that happens. Client-side code runs in the browser and faces none of these server-only limits.
data/customers.server.ts:
import { server, db } from '@aha-app/builder-core';
import { customersTable, type Customer } from '@/db/schema';
export async function getCustomers(): Promise<Customer[]> {
return db.select().from(customersTable);
}
server.data('getCustomers', getCustomers);In client code, reach every server function through the auto-generated @/server module:
import { getCustomers, useGetCustomers, createCustomer } from '@/server';
import { useServerMutation } from '@aha-app/builder-core';
import { toast } from 'sonner';
// Hook (fetches on mount, re-fetches when args change)
const customersQuery = useGetCustomers();
const { data, loading, error } = customersQuery;
// Async (for non-React code or custom flows)
const customers = await getCustomers();
// Wrap a server function with useServerMutation for React event handlers
const create = useServerMutation(createCustomer, {
query: customersQuery,
onError: () => toast.error('Failed to create customer'),
refetchOnSuccess: true,
});Server functions also carry your application's authentication options and content types, and they back both background processing and the scheduled work in cron/.
Connected integrations
Reach the services your team already uses through connections to third-party services, available to server functions from @aha-app/builder-core. Use connections for first-class integrations such as Slack, Zendesk, OpenAI, and Aha! software rather than wiring up provider API keys or OAuth tokens yourself.
import { server, connections } from '@aha-app/builder-core';
server.data('sendSlackMessage', async () => {
const response = await connections.request('slack', {
method: 'POST',
path: '/api/chat.postMessage',
body: {
channel: '#general',
text: 'Hello',
},
});
return await response.json();
});Follow these rules:
Server functions only. Do not import
connectionsin client components.Start
pathwith a slash. It is a path on the provider's API, not a full URL.Split your parameters. Use
queryfor query parameters andbodyfor JSON request bodies.Leave the credentials alone. Do not send
Authorization,Cookie,Host, orX-Api-Keyheaders. The connected integration handles authentication.Use a direct
fetchfor secrets you added yourself. Read them fromprocess.env.SECRET_NAME. That path is unmanaged and separate from connected integrations.Never swallow a connection error.
connections.requestthrowsNotConnectedErrororNotAuthenticatedError(both from@aha-app/builder-core) when an integration is removed or unauthenticated. Let them propagate, or catch them deliberately.
Database
Every application stores its data in a built-in PostgreSQL database. Define your schemas with the Drizzle object-relational mapping (ORM) library in db/schema.ts, and evolve the structure over time with SQL migration files in db/migrations/, which run in order.
import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core';
export const customersTable = pgTable('customers', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull(),
name: text('name').notNull(),
createdAt: timestamp('created_at').defaultNow(),
});
export type Customer = typeof customersTable.$inferSelect;
export type NewCustomer = typeof customersTable.$inferInsert;Export $inferSelect and $inferInsert types next to every table, and use them for server inputs, return types, optimistic UI state, and database result arrays. Register exported server handlers by identifier so the generated @/server hooks infer types. Use pgEnum for finite values such as status, stage, priority, type, category, or condition.
Migrations
SQL migration files live in db/migrations/ and run in sorted order. Each migration runs only once — the framework tracks what it has already executed and skips those files on later runs.
Never edit an existing migration file. It has almost certainly run already and will not run again. To change an existing table, write a new migration.
db/migrations/001_create_customers.sql:
CREATE TABLE IF NOT EXISTS customers (
id SERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
name TEXT NOT NULL,
email TEXT,
created_at TIMESTAMP DEFAULT NOW()
);db/migrations/002_add_phone_to_customers.sql:
ALTER TABLE customers ADD COLUMN phone TEXT;Query your tables through the db client:
import { db } from '@aha-app/builder-core';
import { eq } from 'drizzle-orm';
import { customersTable } from './schema.ts';
...
const customers = await db.select().from(customersTable).where(eq(customersTable.name, 'Chris'));Application icon
Reference your application's logo at /app-icon.
Secrets
Read the secrets you store for your application in server functions through process.env.SECRET_NAME. Client code cannot reach them.
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.