Frontend Architecture
The frontend is a static app shell that compiles TSX pages at runtime. Custom pages run inside a sandboxed iframe that communicates with the parent via postMessage RPC. This page explains each layer in detail.
Compilation pipeline
Section titled “Compilation pipeline”Page compilation is a three-stage process split across the parent app and the sandbox iframe:
1. Transform (Sucrase) — in parent
Section titled “1. Transform (Sucrase) — in parent”Raw TSX source is transformed to JavaScript using Sucrase. Sucrase handles TypeScript types, JSX syntax, and import/export statements in a single fast pass.
TSX source → Sucrase → CommonJS JavaScriptSucrase is configured with three transforms: typescript, jsx, and imports. The output uses CommonJS (require / module.exports) so the next stage can intercept imports. Compilation errors are caught in the parent and displayed without sending code to the iframe.
2. Execute (module loader) — in iframe
Section titled “2. Execute (module loader) — in iframe”The compiled JavaScript is sent to the sandboxed iframe via postMessage. Inside the iframe, it is wrapped in a Function constructor with a custom require function injected. When the code calls require('@alchemify/ui'), the custom loader resolves it from the iframe’s module registry — a plain object mapping package names to their exports.
// Simplified — what happens inside the iframeconst fn = new Function('require', 'module', 'exports', code);fn(customRequire, module, exports);The loader then extracts the React component from the module’s exports, checking in order:
module.exportsitself (if it’s a function)module.exports.default- The first export that is a function
3. Render — in iframe
Section titled “3. Render — in iframe”The iframe mounts the extracted React component inside its own QueryClientProvider and error boundary. The iframe reports content height changes to the parent via postMessage so the parent can auto-size the iframe element.
Iframe sandbox
Section titled “Iframe sandbox”Custom pages execute inside an iframe with sandbox="allow-scripts allow-forms" (no allow-same-origin). This gives each page an opaque origin, isolating it from:
- The parent app’s DOM, localStorage, cookies, and session
- Direct network access (the server rejects requests from opaque origins via CORS)
- Other browser tabs and iframes
A CSP meta tag (connect-src 'none'; img-src data:) provides additional defense-in-depth against data exfiltration via fetch/XHR/WebSocket and image URLs.
postMessage RPC
Section titled “postMessage RPC”The iframe cannot make API calls directly. Instead, @alchemify/data’s rawSql and exec functions send RPC requests to the parent via postMessage:
Page calls rawSql(query, params) → iframe posts {id, method: "rawSql", args: [query, params]} to parent → parent validates method + args, proxies call with JWT → parent posts {id, result} or {id, error} back to iframe → Promise resolves/rejects in page codeThe parent enforces a strict method allowlist (rawSql only), validates argument types, checks payload size (1 MB limit), and applies a 5-second timeout per request.
Module registry
Section titled “Module registry”Pages import functionality from @alchemify/* packages. These are not npm packages — they’re entries in a registry object that the app shell populates at boot.
| Package | What it provides |
|---|---|
@alchemify/ui | Button, Card (+ sub-components), Input, Badge, Table (+ sub-components), DataTable, Label, Checkbox, Textarea, AlertDialog (+ sub-components), Wizard, WizardStep, useWizard, FileField — shadcn/ui components + file upload widget |
@alchemify/data | useSql, useQuery, useMutation, useQueryClient, exec, rawSql |
@alchemify/sql | sql tagged template, joinSQL, raw |
@alchemify/auth | useAuth() — current user |
@alchemify/nav | useNavigate, useParams, useSearchParams, Link, NavLink |
@alchemify/layout | Page, PageHeader, Loading, ErrorBoundary |
@alchemify/util | cn() (class merger), formatDate(), toast(), groupBy(), nest() |
Example page
Section titled “Example page”import { Page, PageHeader } from '@alchemify/layout';import { useAuth } from '@alchemify/auth';import { Card, CardHeader, CardTitle, CardContent } from '@alchemify/ui';
export default function Dashboard() { const user = useAuth(); return ( <Page> <PageHeader title={`Welcome, ${user.name}`} description="Your dashboard" /> <Card> <CardHeader><CardTitle>Hello</CardTitle></CardHeader> <CardContent>This is a TSX page stored in the database.</CardContent> </Card> </Page> );}Error handling
Section titled “Error handling”Errors are caught at three levels:
- Compile-time (parent) — if Sucrase can’t parse the source, the parent shows a
CompilationErrorcard without sending code to the iframe. - Execute-time (iframe) — if the
Functionconstructor or module loader fails, the iframe reports{type: "error", phase: "execute"}to the parent. - Runtime (iframe) — if the component throws during React rendering, an error boundary catches it and reports
{type: "error", phase: "render"}to the parent.
All error states are non-fatal — the rest of the app (sidebar, layout) stays functional.
AI error feedback loop
Section titled “AI error feedback loop”When the AI proposes a page via propose_create_page or propose_update_page, the builder preview renders it immediately in pending_approval state. If the preview detects an error, the system automatically feeds it back to the AI so it can fix its own mistake without user intervention.
Error types detected:
| Error type | Source | How it’s caught |
|---|---|---|
| Compilation error | Sucrase fails to parse the TSX | PageRenderer onError callback |
| Runtime render error | React component throws during render | ErrorBoundary in PageRenderer |
| Database query error | useSql/exec call fails | Dedicated QueryClient with QueryCache.onError in PreviewQueryProvider |
How it works:
- The AI streams a
tool_callevent withpropose_create_pageorpropose_update_page. PageWidgetrenders the proposed source in a live preview usingPageRenderer.PageRendererreports errors via itsonErrorcallback (compilation and render errors) and viaPreviewQueryProvider’sQueryCache.onError(database errors).PageWidgetdetects the error and checks whether it is an infrastructure error (see filtering below). If it is, the error is ignored.- For real page errors,
PageWidgetauto-rejects the pending tool call viaexecute-toolwithapproved: false. ChatThreadsends the error details (error message and full page source) back to the AI as a new user message.- The AI receives the error context and generates a corrected page in a new tool call.
Infrastructure error filtering:
Not all errors indicate a problem with the AI-generated page. PageWidget filters out known infrastructure errors that are unrelated to the page source:
- CSP violations — Content Security Policy blocks from the preview sandbox
- HMR errors — Vite Hot Module Replacement noise during development
- Vite/bundler errors — Build tool artifacts that leak into error boundaries
These are identified by pattern-matching the error message. Only errors that indicate actual page defects (bad syntax, undefined variables, failed SQL queries) trigger the feedback loop.
Retry limit:
The system allows a maximum of one auto-retry per tool call to prevent infinite loops. If the AI’s second attempt also fails, the error is shown to the user in the preview widget and no further automatic retries occur. The user can still manually intervene via the chat.
System prompt guidance:
The chat service system prompt (in apps/chat/src/ai.ts) includes explicit guidance to help the AI avoid common errors that trigger this loop — for example, never importing modules that are not in the @alchemify/* registry. This reduces the frequency of errors that need automatic correction.
Key files:
| File | Role |
|---|---|
apps/web/src/components/PageRenderer.tsx | onError callback, PreviewQueryProvider with QueryCache.onError |
apps/web/src/components/builder/widgets/PageWidget.tsx | Error detection, infrastructure filtering, auto-reject |
apps/web/src/components/builder/ChatThread.tsx | Auto-retry orchestration, sends error context to AI |
apps/chat/src/ai.ts | System prompt with “never import” guidance |
apps/chat/src/app.ts | Handles rejected tool_result in conversation history |
Adding a new module
Section titled “Adding a new module”To make a new package available to TSX pages:
- Create a module file, e.g.
apps/web/src/modules/charts.ts, exporting the components. - Add the module to both registries:
apps/web/src/compiler/registry.ts— used by the builder preview (PageRenderer)apps/web/src/sandbox/registry.ts— used by the sandboxed iframe runtime
- If the module needs network access (like
@alchemify/data), the sandbox version must use the RPC client instead of directfetch. - If adding to
@alchemify/ui, update the canonical component registry inpackages/types/src/ui-components.ts. The chat service reads this at runtime to tell the AI exactly which components are available. A test automatically verifies the registry matchesui.ts. - Rebuild the app — pages can now
import { BarChart } from '@alchemify/charts'.
Note: FileField from @alchemify/ui is not available in sandboxed pages because it requires direct API access for file uploads. The sandbox UI module exports all other components.
App context
Section titled “App context”The frontend supports multiple apps (schema-per-app). Each app maps to a PostgreSQL schema registered in sys.apps. The active app is encoded in the URL as a path prefix (/:schema/*), making it bookmarkable and shareable.
URL structure
Section titled “URL structure”/:schema/* — all app-scoped routes/apps — app picker / management/login, /signup, /set-password — auth routes (global)/ — smart redirect (last-used → /:schema, else → /apps)How it works
Section titled “How it works”AppProvider accepts a schema prop derived from useParams().schema in the parent route. It syncs the schema to the api.ts module variable and localStorage so every apiFetch call includes the X-App-Schema HTTP header. The backend’s resolveAppSchema() validates the header and sets search_path accordingly.
The appUrl(path) helper in routes.ts prefixes any app-scoped path with /${schema}. CRUD components use entityPath()/entityUrl() which call appUrl() internally, so they get schema-prefixed paths with zero callsite changes.
Key components:
| Component | Purpose |
|---|---|
AppContext / useApp() | Provides activeSchema, setActiveSchema(), clearActiveSchema() |
AppPicker | Full-page picker at /apps for selecting or creating an app (admin/owner can create) |
AppSwitcher | Sidebar popover for switching, renaming, or deleting apps |
Switching apps
Section titled “Switching apps”setActiveSchema(name) updates localStorage, sets the module-level header variable, calls queryClient.clear() to prevent cross-app data leakage, and navigates to /${name}. clearActiveSchema() navigates to /apps. The schema query key includes the active schema (["schema", activeSchema]), so TanStack Query auto-refetches on switch.
A useEffect in AppProvider clears the query cache when the schema prop changes via URL (browser back/forward, URL bar edits) to prevent cross-app data leakage for navigations that bypass setActiveSchema().
Error recovery
Section titled “Error recovery”If the schema fetch fails while an app is selected (e.g., the schema was deleted), SchemaContext auto-clears the active schema, returning the user to /apps.
Live updates
Section titled “Live updates”When a page is inserted, updated, or deleted, a trigger fires pg_notify('page_change', slug) with the affected slug. The app shell listens for these notifications and re-fetches the page source. The parent recompiles with Sucrase and sends the new compiled JS to the iframe, which unmounts the old component and mounts the new one.