The app/ directory holds your project’s frontend: a standard Vite and React single-page app served from the same origin as your functions. In development one dev server runs both, so the app calls your API with plain fetch and no CORS setup; in production the built assets ship to S3 behind CloudFront. The directory is optional, and API-only projects don’t have one.
Project structure
helix init --with-app scaffolds the app:
app/
├── index.html # Vite entry point
├── vite.config.ts # Standard Vite config
├── tsconfig.json # Extends root, targets browser/DOM
├── tailwind.config.ts # Tailwind config with the @trayai/helix-ui preset
├── postcss.config.js # PostCSS for Tailwind
├── src/
│ ├── main.tsx # React root
│ ├── App.tsx # Main app component
│ ├── index.css # Tailwind + CSS variables for theming
│ ├── components/ # React components
│ ├── pages/ # Route pages
│ └── lib/ # Client-side utilities
└── public/ # Static assets (favicon, images, etc.)
The CLI detects whether app/ exists and adjusts the dev server and build pipeline accordingly. There is nothing Helix-specific inside the app itself.
One origin in development
During helix dev, Vite runs as a plugin inside the same dev server that runs your functions. Every request hits one server:
localhost:3000 (dev server)
├── Function route match → function runtime (builds and runs .ts files)
├── Static file match → Vite (public/ and built assets)
└── No match → Vite (serves index.html for SPA routing)
This means:
- The SPA calls function APIs at the same origin (
fetch('/api/v1/users')), so no CORS configuration is needed. - Vite HMR works normally: save a React component and see it update instantly.
- Functions hot-reload too: save a function file and the next request uses the new version.
- You open
localhost:3000and everything is there.
The Vite config stays standard, with no proxy setup and no Helix plugins:
// app/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
// No proxy config needed, the Helix CLI handles routing
});
Calling functions from the app
Because the app and the API share an origin, API calls are plain fetch with relative paths:
// app/src/lib/api.ts
const API_BASE = '/api/v1';
export async function getUsers() {
const res = await fetch(`${API_BASE}/users`);
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
return res.json();
}
export async function createUser(data: { name: string; email: string }) {
const res = await fetch(`${API_BASE}/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
return res.json();
}
See functions and routing for the API side.
The component library
Projects created with helix init --with-app include @trayai/helix-ui by default: a component library based on shadcn/ui, shipped as an npm package. You get:
- 40+ accessible, pre-built components (Button, Dialog, Select, Table, and more)
- Radix UI primitives underneath
- Tailwind CSS styling with CSS variable theming
- Built-in dark mode with a class-based toggle
- Automatic inheritance of your org’s theme
No component source code lives in your repo; everything is imported from the package:
// app/src/pages/Dashboard.tsx
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@trayai/helix-ui';
export function Dashboard() {
return (
<div className="p-6">
<Dialog>
<DialogTrigger asChild>
<Button>Open Dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Welcome</DialogTitle>
</DialogHeader>
<p>This is a Helix UI dialog component.</p>
</DialogContent>
</Dialog>
</div>
);
}
| Category | Components |
|---|---|
| Layout and navigation | Accordion, Tabs, NavigationMenu, Menubar, ContextMenu, Breadcrumb |
| Forms and inputs | Button, Input, Textarea, Select, Checkbox, RadioGroup, Switch, Slider, Label, Form |
| Data display | Table, Card, Badge, Avatar, Separator, Skeleton |
| Feedback | Alert, AlertDialog, Toast, Dialog, Sheet, Popover, HoverCard, Tooltip |
| Overlays | DropdownMenu, Command, Combobox, Calendar, DatePicker |
| Utilities | Aspect Ratio, Collapsible, Resizable, ScrollArea, Toggle, ToggleGroup |
Tailwind preset
The package ships a Tailwind preset that wires up the CSS variables. Your tailwind.config.ts extends it:
// app/tailwind.config.ts
import type { Config } from 'tailwindcss';
import { helixUIPreset } from '@trayai/helix-ui/tailwind';
export default {
presets: [helixUIPreset],
content: [
'./index.html',
'./src/**/*.{js,ts,jsx,tsx}',
'./node_modules/@trayai/helix-ui/**/*.js', // Include the component library
],
} satisfies Config;
The preset provides CSS variable mappings for all theme colors, border radius utilities tied to --radius, animation utilities, and the class-based dark mode strategy.
Theming with CSS variables
All colors come from CSS variables defined in app/src/index.css: --background, --foreground, --primary, --secondary, --muted, --accent, --destructive, --border, --input, --ring, and --radius, each with a matching -foreground pair where relevant. Light values live under :root, dark values under .dark. Edit the file to change the theme:
/* app/src/index.css */
@layer base {
:root {
--primary: 142.1 76.2% 36.3%; /* Green instead of the default blue */
--primary-foreground: 355.7 100% 97.3%;
--radius: 0.75rem; /* Rounder corners */
}
}
Quick changes: set --primary to your brand color, adjust --radius for corner rounding (0 is square, 1rem is very rounded), or swap the whole scheme by replacing all HSL values. Toggle dark mode by adding or removing the dark class on document.documentElement.
Org-level theming
Organizations can define a default theme (colors, logo, favicon) in the dashboard under Settings, Organization, Branding. When you run helix init --with-app, the CLI:
- Fetches the org theme from the Helix API
- Injects the CSS variables into
app/src/index.css - Copies the logo and favicon to
app/public/ - Updates
app/index.htmlwith the favicon reference
The org theme is applied only at project creation. The CLI never overwrites app/src/index.css afterward, so project-level edits stick. When the org updates its theme, pull it explicitly:
helix theme pull
This rewrites app/src/index.css with the new org colors. Review and commit the change, or discard it to keep your current theme. Theme changes are explicit commits, never silent updates.
Other component setups
You can opt out of @trayai/helix-ui at init time:
| Flag | Result |
|---|---|
helix init --with-app --raw-ui | Raw shadcn/ui: components are copied into app/src/components/ui/ as TypeScript source. You own the code, customize freely, and manage updates yourself. |
helix init --with-app --no-ui | No component library at all. Install Material-UI, Chakra UI, Ant Design, or use plain Tailwind. |
In an existing project you can also run npx shadcn@latest init and npx shadcn@latest add button dialog table inside app/ to set up raw shadcn/ui manually.
Production serving
helix build builds both sides, and helix deploy ships them:
helix build
# .helix/build/functions/ bundled function files
# .helix/build/app/ Vite build output (index.html, assets/)
helix deploy
# Functions → the Helix function runtime
# SPA assets → S3, served via CloudFront with caching headers
In production the SPA and functions share the project domain:
https://{project-id}.helix-app.ai/
├── / → SPA (index.html)
├── /dashboard → SPA (client-side route, served as index.html)
├── /api/v1/users → Function
├── /assets/main-abc123.js → Static asset (Vite build output)
└── /favicon.ico → Static asset (from app/public/)
Routing priority is the same as in dev: function routes first, then static files, then the SPA fallback serving index.html for client-side routes.
Project shapes
- Full-stack (both
functions/andapp/): the standard setup. Functions handle server-side logic, the SPA handles the UI, and they share an origin. - API-only (no
app/): no SPA fallback, so unmatched routes return 404. - SPA-only (no
functions/): a pure static app. All routes serveindex.html, and the app can still call external APIs directly from the browser.