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

typescript
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:

tsx
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

tsx
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:

typescript
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:

tsx
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:

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

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

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

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

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

tsx
// 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:

tsx
// 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:

tsx
'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:

tsx
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:

tsx
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 CaseRecommended LevelWhy
Complete admin panelLevel 4 (AdminUI)Out-of-the-box functionality
Custom dashboardLevel 3 (Standalone)Compose admin features
Checkout flowLevel 2 (Fields)Custom form layout
Login pageLevel 1 (Primitives)Full design control
Multi-step wizardLevel 3 (Standalone)Reuse form logic
Embedded formsLevel 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