OpenSaas UI Composability Guide
OpenSaas provides a fully composable UI layer with four levels of abstraction. Choose the level that best fits your use case - from low-level primitives to a complete admin interface.
The Four Levels
Level 4: AdminUI ──────────────┐ (Complete admin interface)
│
Level 3: Standalone ───────────┤ (ItemCreateForm, ListTable, SearchBar)
│
Level 2: Fields ───────────────┤ (TextField, SelectField, RelationshipField)
│
Level 1: Primitives ───────────┘ (Button, Input, Card, Table, Dialog)
Each level builds on the one below, providing progressively higher-level abstractions.
Level 1: Primitives
What: Low-level UI components based on Radix UI and shadcn/ui When to use: Building completely custom UIs with full control Import from: @opensaas/stack-ui/primitives
Available Primitives
- Button - Buttons with variants (default, destructive, outline, secondary, ghost, link)
- Input - Text inputs with validation states
- Label - Accessible form labels
- Card - Content containers (Card, CardHeader, CardTitle, CardContent, CardFooter)
- Table - Data tables (Table, TableHeader, TableBody, TableRow, TableHead, TableCell)
- Dialog - Modal dialogs (Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter)
- Select - Dropdown selects with search
- Checkbox - Checkboxes with indeterminate state
Example: Custom Login Form
Every primitive comes from the one @opensaas/stack-ui/primitives entry point — there are no per-component subpaths:
import {
Button,
Input,
Label,
Card,
CardContent,
CardHeader,
CardTitle,
} from '@opensaas/stack-ui/primitives'
export function LoginForm() {
return (
<Card className="w-[400px]">
<CardHeader>
<CardTitle>Sign In</CardTitle>
</CardHeader>
<CardContent>
<form className="space-y-4">
<div>
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="you@example.com" />
</div>
<div>
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" />
</div>
<Button type="submit" className="w-full">
Sign In
</Button>
</form>
</CardContent>
</Card>
)
}
Level 2: Field Components
What: OpenSaas-aware form fields with validation and field-level access control When to use: Building custom forms that integrate with OpenSaas field configs Import from: @opensaas/stack-ui/fields
Available Fields
- TextField - Text inputs
- IntegerField - Number inputs
- CheckboxField - Boolean checkboxes
- SelectField - Select dropdowns
- PasswordField - Password inputs with confirmation
- TimestampField - Date/time pickers
- RelationshipField - Foreign key selects
Example: Custom Checkout Form
import { TextField, SelectField } from '@opensaas/stack-ui/fields'
import { Button } from '@opensaas/stack-ui/primitives'
export function CheckoutForm() {
const [email, setEmail] = useState('')
const [plan, setPlan] = useState('')
const plans = [
{ label: 'Starter - $10/mo', value: 'starter' },
{ label: 'Pro - $25/mo', value: 'pro' },
{ label: 'Enterprise - $99/mo', value: 'enterprise' },
]
return (
<form onSubmit={handleSubmit} className="space-y-6">
<TextField
name="email"
label="Email Address"
value={email}
onChange={setEmail}
required
mode="edit"
/>
<SelectField
name="plan"
label="Select Plan"
value={plan}
onChange={setPlan}
options={plans}
required
mode="edit"
/>
<Button type="submit">Subscribe Now</Button>
</form>
)
}
Field Props Interface
All field components share a common interface:
type FieldComponentProps = {
name: string
value: unknown
onChange: (value: unknown) => void
label: string
error?: string
disabled?: boolean
required?: boolean
mode?: 'read' | 'edit'
}
value and onChange are unknown on the shared type — each field type narrows them for itself, and a component receives whatever extra props the field's ui options carry on top. Import the type from @opensaas/stack-ui/fields.
Level 3: Standalone Components
What: Complete, reusable components for common admin tasks When to use: Embedding admin functionality in custom pages Import from: @opensaas/stack-ui/standalone
Available Components
ItemCreateForm
Standalone form for creating items with custom submission handling.
config here is the resolved config from the generated bundle. Its default export is a promise when plugins are present, so a server component awaits it before reading lists:
import { ItemCreateForm } from '@opensaas/stack-ui/standalone'
import { config } from '@/.opensaas/context'
export async function CreatePostPage() {
const router = useRouter()
const { lists } = await config
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">Create New Post</h1>
<ItemCreateForm
fields={lists.Post.fields}
onSubmit={async (data) => {
try {
const post = await createPost(data)
if (!post) return { success: false, error: 'Could not create the post' }
toast.success(`Created "${post.title}"`)
router.push(`/posts/${post.id}`)
return { success: true }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) }
}
}}
onCancel={() => router.back()}
submitLabel="Publish Post"
/>
</div>
)
}
Props:
interface ItemCreateFormProps {
fields: Record<string, FieldConfig>
listKey?: string
config?: OpenSaasConfig
onSubmit: (data: Record<string, unknown>) => Promise<{ success: boolean; error?: string }>
onCancel?: () => void
relationshipData?: Record<string, Array<{ id: string; label: string }>>
submitLabel?: string
cancelLabel?: string
className?: string
classNames?: ItemFormClassNames
}
ItemEditForm
Standalone form for editing existing items.
import { ItemEditForm } from '@opensaas/stack-ui/standalone'
import { config } from '@/.opensaas/context'
export async function EditPostPage({ post }) {
const { lists } = await config
return (
<ItemEditForm
fields={lists.Post.fields}
initialData={post}
onSubmit={async (data) => {
const updated = await updatePost(post.id, data)
return { success: !!updated }
}}
onCancel={() => router.push(`/posts/${post.id}`)}
/>
)
}
ListTable
Standalone table for displaying lists with sorting and actions.
import { ListTable } from '@opensaas/stack-ui/standalone'
export function RecentPosts({ posts }) {
return (
<ListTable
items={posts}
fieldTypes={{
title: 'text',
author: 'relationship',
publishedAt: 'timestamp',
status: 'select',
}}
columns={['title', 'author', 'publishedAt', 'status']}
onRowClick={(post) => router.push(`/posts/${post.id}`)}
renderActions={(post) => (
<DeleteButton
onDelete={async () => {
// A denied delete answers `null`, so report the denial rather
// than a success the caller never got.
const deleted = await deletePost(post.id)
return { success: !!deleted }
}}
/>
)}
sortable
/>
)
}
SearchBar
Reusable search component with clear functionality.
import { SearchBar } from '@opensaas/stack-ui/standalone'
export function PostsPage() {
const [search, setSearch] = useState('')
return (
<div>
<SearchBar
onSearch={(query) => {
setSearch(query)
fetchPosts({ search: query })
}}
onClear={() => {
setSearch('')
fetchPosts({})
}}
placeholder="Search posts..."
defaultValue={search}
/>
</div>
)
}
DeleteButton
Delete button with built-in confirmation dialog.
import { DeleteButton } from '@opensaas/stack-ui/standalone'
export function PostActions({ postId }) {
return (
<DeleteButton
onDelete={async () => {
const deleted = await deletePost(postId)
if (!deleted) return { success: false, error: 'Not found, or you cannot delete it' }
router.push('/posts')
return { success: true }
}}
itemName="post"
confirmMessage="This will permanently delete the post and all its comments."
buttonVariant="destructive"
/>
)
}
Level 4: Full Admin UI
What: Complete admin interface with routing, navigation, and CRUD operations When to use: When you need a full-featured admin panel out of the box Import from: @opensaas/stack-ui
Example: Complete Admin Route
There is no getAdminContext helper. Your route resolves the session itself and builds the context with getContext from the generated .opensaas/context module — the same context every other part of your app uses, so AdminUI runs under exactly the access control your config declares.
@opensaas/stack-ui/server is types-only; ServerActionInput is what you import from it, to type the 'use server' wrapper AdminUI calls for mutations.
// app/admin/[[...admin]]/page.tsx
import { AdminUI } from '@opensaas/stack-ui'
import type { ServerActionInput } from '@opensaas/stack-ui/server'
import { getContext, config } from '@/.opensaas/context'
import { getSession } from '@/lib/auth'
async function serverAction(props: ServerActionInput) {
'use server'
const session = await getSession()
const context = await getContext(session ?? undefined)
return context.serverAction(props)
}
interface AdminPageProps {
params: Promise<{ admin?: string[] }>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export default async function AdminPage({ params, searchParams }: AdminPageProps) {
const { admin } = await params
const resolvedSearchParams = await searchParams
const session = await getSession()
return (
<AdminUI
context={await getContext(session ?? undefined)}
config={await config}
params={admin}
searchParams={resolvedSearchParams}
basePath="/admin"
serverAction={serverAction}
/>
)
}
This provides:
- Dashboard with list cards
- List views with search and pagination
- Create/edit forms
- Delete confirmations
- Navigation sidebar
- Field-level access control
- Relationship handling
Linking back to a host app: the chrome slot
Mounting AdminUI at a sub-path inside a larger app (incremental adoption, alongside a bespoke top-level admin) needs a way for its sidebar to link back out. Two props cover this without reopening "swap the components inside AdminUI" — chrome may be replaced wholesale at the mount site, but components inside the built-in chrome still can't be individually swapped:
// The one-line path: add a link to the built-in sidebar
<AdminUI {...props} navItems={[{ label: 'Back to App', href: '/' }]} />
// The full-control path: supply your own sidebar entirely
<AdminUI {...props} navigation={<MyOwnSidebar />} />
navigation replaces the sidebar wholesale (and skips the built-in nav-count resolution, since your own chrome resolves its own counts); navItems adds entries to the built-in one. See the UI reference for the full API, including the public NavLink component and the deriveCurrentPath helper for computing active state in your own chrome.
Real-World Use Cases
Use Case 1: Multi-Step Wizard
Combine standalone components for complex workflows:
A client component cannot await config itself, so a server component reads the field configs and passes them down as props:
'use client'
export function OnboardingWizard({ userFields, subscriptionFields }) {
const [step, setStep] = useState(1)
const [userId, setUserId] = useState('')
return (
<div className="max-w-2xl mx-auto">
{step === 1 && (
<ItemCreateForm
fields={userFields}
onSubmit={async (data) => {
const user = await createUser(data)
if (!user) return { success: false, error: 'Could not create the user' }
setUserId(user.id)
setStep(2)
return { success: true }
}}
submitLabel="Next: Choose Plan"
/>
)}
{step === 2 && (
<ItemCreateForm
fields={subscriptionFields}
onSubmit={async (data) => {
// `ItemCreateForm` has no `initialData` — that is `ItemEditForm`'s
// prop. Carry state the form does not collect through the handler.
const subscription = await createSubscription({ ...data, userId })
if (!subscription) return { success: false, error: 'Could not create the subscription' }
router.push('/dashboard')
return { success: true }
}}
submitLabel="Complete Setup"
/>
)}
</div>
)
}
Use Case 2: Custom Dashboard
Build a custom dashboard using primitives and standalone components:
import { Card, CardContent, CardHeader, CardTitle } from '@opensaas/stack-ui/primitives'
import { ListTable, SearchBar } from '@opensaas/stack-ui/standalone'
import { ItemCreateForm } from '@opensaas/stack-ui/standalone'
import { Dialog, DialogContent } from '@opensaas/stack-ui/primitives'
export function CustomDashboard({ postFields }) {
const [showCreate, setShowCreate] = useState(false)
return (
<div className="p-8">
<div className="grid grid-cols-3 gap-6">
{/* Stats Cards */}
<Card>
<CardHeader>
<CardTitle>Total Posts</CardTitle>
</CardHeader>
<CardContent>
<p className="text-4xl font-bold">{stats.posts}</p>
</CardContent>
</Card>
{/* Recent Posts */}
<Card className="col-span-2">
<CardHeader>
<CardTitle>Recent Posts</CardTitle>
</CardHeader>
<CardContent>
<SearchBar onSearch={handleSearch} />
<ListTable
items={recentPosts}
fieldTypes={{ title: 'text', createdAt: 'timestamp' }}
onRowClick={(post) => router.push(`/posts/${post.id}`)}
/>
</CardContent>
</Card>
</div>
{/* Create Dialog */}
<Dialog open={showCreate} onOpenChange={setShowCreate}>
<DialogContent>
<ItemCreateForm
fields={postFields}
onSubmit={async (data) => {
const post = await createPost(data)
if (!post) return { success: false, error: 'Could not create the post' }
setShowCreate(false)
return { success: true }
}}
/>
</DialogContent>
</Dialog>
</div>
)
}
Use Case 3: Inline Editing
Toggle between view and edit modes:
export function PostDetailPage({ post, postFields }) {
const [editing, setEditing] = useState(false)
if (editing) {
return (
<ItemEditForm
fields={postFields}
initialData={post}
onSubmit={async (data) => {
const updated = await updatePost(post.id, data)
if (!updated) return { success: false, error: 'Could not save your changes' }
setEditing(false)
return { success: true }
}}
onCancel={() => setEditing(false)}
/>
)
}
return (
<div>
<h1>{post.title}</h1>
<p>{post.content}</p>
<Button onClick={() => setEditing(true)}>Edit</Button>
</div>
)
}
Choosing the Right Level
| Use Case | Recommended Level | Why |
|---|---|---|
| Complete admin panel | Level 4 (AdminUI) | Out-of-the-box functionality |
| Custom dashboard | Level 3 (Standalone) | Compose admin features |
| Checkout flow | Level 2 (Fields) | Custom form layout |
| Login page | Level 1 (Primitives) | Full design control |
| Multi-step wizard | Level 3 (Standalone) | Reuse form logic |
| Embedded forms | Level 3 (Standalone) | Drop-in components |
Styling and Theming
All components share a single set of theme tokens (CSS custom properties like --color-primary, --radius, and --shadow-md). You restyle everything at once either from config via ui.theme in opensaas.config.ts or by overriding the --color-* tokens in your own stylesheet, and dark mode is driven by a data-theme attribute on <html>.
See the Theming guide for the full token vocabulary, ThemeConfig, dark mode, and the component customization ladder, and the Theme Presets gallery for the built-in modern / classic / neon starting points.
Accessibility
All primitives are built with Radix UI, providing:
- ARIA attributes
- Keyboard navigation
- Focus management
- Screen reader support
- Proper semantic HTML
Next Steps
- See the UI reference for complete prop documentation
- See the Config API reference for field and list options
- Read the Custom fields guide for extending field types
- Explore the composable dashboard example for working code