Skip to content

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.

Page compilation is a three-stage process split across the parent app and the sandbox iframe:

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 JavaScript

Sucrase 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.

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 iframe
const 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:

  1. module.exports itself (if it’s a function)
  2. module.exports.default
  3. The first export that is a function

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.

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.

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 code

The 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.

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.

PackageWhat it provides
@alchemify/uiButton, 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/datauseSql, useQuery, useMutation, useQueryClient, exec, rawSql
@alchemify/sqlsql tagged template, joinSQL, raw
@alchemify/authuseAuth() — current user
@alchemify/navuseNavigate, useParams, useSearchParams, Link, NavLink
@alchemify/layoutPage, PageHeader, Loading, ErrorBoundary
@alchemify/utilcn() (class merger), formatDate(), toast(), groupBy(), nest()
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>
);
}

Errors are caught at three levels:

  1. Compile-time (parent) — if Sucrase can’t parse the source, the parent shows a CompilationError card without sending code to the iframe.
  2. Execute-time (iframe) — if the Function constructor or module loader fails, the iframe reports {type: "error", phase: "execute"} to the parent.
  3. 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.

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 typeSourceHow it’s caught
Compilation errorSucrase fails to parse the TSXPageRenderer onError callback
Runtime render errorReact component throws during renderErrorBoundary in PageRenderer
Database query erroruseSql/exec call failsDedicated QueryClient with QueryCache.onError in PreviewQueryProvider

How it works:

  1. The AI streams a tool_call event with propose_create_page or propose_update_page.
  2. PageWidget renders the proposed source in a live preview using PageRenderer.
  3. PageRenderer reports errors via its onError callback (compilation and render errors) and via PreviewQueryProvider’s QueryCache.onError (database errors).
  4. PageWidget detects the error and checks whether it is an infrastructure error (see filtering below). If it is, the error is ignored.
  5. For real page errors, PageWidget auto-rejects the pending tool call via execute-tool with approved: false.
  6. ChatThread sends the error details (error message and full page source) back to the AI as a new user message.
  7. 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:

FileRole
apps/web/src/components/PageRenderer.tsxonError callback, PreviewQueryProvider with QueryCache.onError
apps/web/src/components/builder/widgets/PageWidget.tsxError detection, infrastructure filtering, auto-reject
apps/web/src/components/builder/ChatThread.tsxAuto-retry orchestration, sends error context to AI
apps/chat/src/ai.tsSystem prompt with “never import” guidance
apps/chat/src/app.tsHandles rejected tool_result in conversation history

To make a new package available to TSX pages:

  1. Create a module file, e.g. apps/web/src/modules/charts.ts, exporting the components.
  2. 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
  3. If the module needs network access (like @alchemify/data), the sandbox version must use the RPC client instead of direct fetch.
  4. If adding to @alchemify/ui, update the canonical component registry in packages/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 matches ui.ts.
  5. 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.

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.

/:schema/* — all app-scoped routes
/apps — app picker / management
/login, /signup, /set-password — auth routes (global)
/ — smart redirect (last-used → /:schema, else → /apps)

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:

ComponentPurpose
AppContext / useApp()Provides activeSchema, setActiveSchema(), clearActiveSchema()
AppPickerFull-page picker at /apps for selecting or creating an app (admin/owner can create)
AppSwitcherSidebar popover for switching, renaming, or deleting 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().

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.

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.