@opensaas/stack-ui

Composable React UI components for building admin interfaces with Stack. Built on top of shadcn/ui primitives with full TypeScript support and multiple levels of abstraction.

Overview

The UI package provides everything you need to build admin interfaces, from complete out-of-the-box solutions to low-level primitives for custom UIs:

  1. AdminUI - Complete admin interface with routing, navigation, and CRUD operations
  2. Standalone Components - Drop-in components for forms, tables, and common UI patterns
  3. Field Components - Individual field inputs with validation and type safety
  4. Primitives - Low-level shadcn/ui components for building custom interfaces

Installation

bash
pnpm add @opensaas/stack-ui

The UI package has peer dependencies on @opensaas/stack-core, next, react, and react-dom.

Quick Start

Full Admin Interface

The simplest way to add an admin interface to your Next.js app:

typescript
// 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'
import { redirect } from 'next/navigation'
import { getUrlKey } from '@opensaas/stack-core'
import '@opensaas/stack-ui/styles'

async function serverAction(props: ServerActionInput) {
  'use server'
  const session = await getSession()
  const context = await getContext(session ?? undefined)
  const result = await context.serverAction(props)
  if (result && typeof result === 'object' && 'success' in result && result.success) {
    redirect(`/admin/${getUrlKey(props.listKey)}`)
  }
  return result
}

interface AdminPageProps {
  params: Promise<{ admin?: string[] }>
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}

export default async function AdminPage({ params, searchParams }: AdminPageProps) {
  const resolvedParams = await params
  const resolvedSearchParams = await searchParams
  const session = await getSession()
  if (!session) {
    return <div>You must be signed in to access the admin interface.</div>
  }
  return (
    <AdminUI
      context={await getContext(session)}
      config={await config}
      params={resolvedParams.admin}
      searchParams={resolvedSearchParams}
      basePath="/admin"
      serverAction={serverAction}
    />
  )
}

This gives you a complete admin interface with:

  • Dashboard with list summaries and quick actions
  • List views with search, sorting, and pagination
  • Create and edit forms with validation
  • Delete confirmation dialogs
  • Responsive navigation sidebar

Admin Dashboard

Package Exports

The UI package uses multiple export paths for different levels of abstraction:

typescript
// Main export - Full admin UI and utilities
import { AdminUI, registerFieldComponent } from '@opensaas/stack-ui'

// Primitives - shadcn/ui components
import { Button, Card, Input, Table } from '@opensaas/stack-ui/primitives'

// Field components - Individual field inputs
import { TextField, SelectField, RelationshipField } from '@opensaas/stack-ui/fields'

// Standalone components - Composable CRUD components
import { ItemCreateForm, ItemEditForm, ListTable } from '@opensaas/stack-ui/standalone'

// Server utilities - Type imports for server actions
import type { ServerActionInput } from '@opensaas/stack-ui/server'

// Styles - Tailwind CSS
import '@opensaas/stack-ui/styles'

Core Components

AdminUI

The AdminUI component provides a complete admin interface with routing, navigation, and CRUD operations.

typescript
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)
}

export default async function AdminPage() {
  const session = await getSession()
  return (
    <AdminUI
      context={await getContext(session ?? undefined)}
      config={await config}
      serverAction={serverAction}
    />
  )
}

Props:

  • context - Access-controlled context from .opensaas/context
  • config - OpenSaas configuration object
  • serverAction - 'use server' wrapper around context.serverAction for mutations

Features:

  • Automatic routing based on URL segments
  • Navigation sidebar with list links
  • Dashboard with list summaries
  • List views with search and sorting
  • Create and edit forms
  • Delete confirmations

Dashboard

The dashboard shows an overview of your lists with item counts and quick actions.

Dashboard

Features:

  • List summary cards with item counts
  • "View all" links to list views
  • Quick action buttons for creating items
  • Responsive grid layout

ListView

The list view displays items in a table with search, sorting, and actions.

List View

Features:

  • Sortable columns
  • Search functionality
  • Edit and delete actions
  • Pagination (coming soon)
  • Responsive table layout

ItemForm

Forms for creating and editing items with validation and field-level access control.

Create Form Edit Form

Features:

  • Automatic field rendering based on field type
  • Client-side and server-side validation
  • Field-level access control
  • Required field indicators
  • Error messages
  • Cancel and delete actions

Standalone Components

Standalone components allow you to build custom admin interfaces by composing individual components.

ItemCreateForm

A standalone form for creating new items.

typescript
import { ItemCreateForm } from '@opensaas/stack-ui/standalone'
import { getContext } from '@/.opensaas/context'
import { getSession } from '@/lib/auth'

export default async function CreatePostPage() {
  const session = await getSession()
  const context = await getContext(session ?? undefined)

  return (
    <ItemCreateForm
      listKey="Post"
      context={context}
      onSuccess={(item) => {
        // Redirect or show success message
        console.log('Created item:', item.id)
      }}
      onError={(error) => {
        // Handle error
        console.error('Failed to create:', error)
      }}
    />
  )
}

Props:

  • listKey - The name of the list (e.g., "Post", "User")
  • context - Admin context with session and database access
  • onSuccess? - Optional callback when item is created successfully
  • onError? - Optional callback when creation fails

ItemEditForm

A standalone form for editing existing items.

typescript
import { ItemEditForm } from '@opensaas/stack-ui/standalone'
import { getContext } from '@/.opensaas/context'
import { getSession } from '@/lib/auth'

export default async function EditPostPage({ params }: { params: { id: string } }) {
  const session = await getSession()
  const context = await getContext(session ?? undefined)

  return (
    <ItemEditForm
      listKey="Post"
      itemId={params.id}
      context={context}
      onSuccess={(item) => {
        console.log('Updated item:', item.id)
      }}
      onError={(error) => {
        console.error('Failed to update:', error)
      }}
    />
  )
}

Props:

  • listKey - The name of the list
  • itemId - The ID of the item to edit
  • context - Admin context
  • onSuccess? - Optional callback when item is updated successfully
  • onError? - Optional callback when update fails

ListTable

A standalone table component for displaying lists of items.

typescript
import { ListTable } from '@opensaas/stack-ui/standalone'
import { getContext } from '@/.opensaas/context'
import { getSession } from '@/lib/auth'

export default async function PostsPage() {
  const session = await getSession()
  const context = await getContext(session ?? undefined)

  return (
    <div>
      <h1>Posts</h1>
      <ListTable
        listKey="Post"
        context={context}
        columns={['title', 'status', 'author', 'createdAt']}
      />
    </div>
  )
}

Props:

  • listKey - The name of the list
  • context - Admin context
  • columns? - Optional array of field names to display (defaults to all fields)

SearchBar

A search input with button for filtering list views.

typescript
import { SearchBar } from '@opensaas/stack-ui/standalone'

export default function SearchPage() {
  return (
    <SearchBar
      placeholder="Search posts..."
      onSearch={(query) => {
        console.log('Searching for:', query)
      }}
    />
  )
}

Props:

  • placeholder? - Placeholder text for the search input
  • onSearch - Callback function when search is submitted

DeleteButton

A button with confirmation dialog for deleting items.

typescript
import { DeleteButton } from '@opensaas/stack-ui/standalone'

export default function ItemActions({ itemId }: { itemId: string }) {
  return (
    <DeleteButton
      listKey="Post"
      itemId={itemId}
      onSuccess={() => {
        console.log('Item deleted')
      }}
      onError={(error) => {
        console.error('Failed to delete:', error)
      }}
    />
  )
}

Props:

  • listKey - The name of the list
  • itemId - The ID of the item to delete
  • onSuccess? - Optional callback when item is deleted successfully
  • onError? - Optional callback when deletion fails

Field Components

Field components are used internally by forms but can also be used directly for custom UIs.

Available Field Components

  • TextField - Text input for string fields
  • IntegerField - Number input for integer fields
  • CheckboxField - Checkbox for boolean fields
  • SelectField - Dropdown for enum fields
  • TimestampField - Date/time picker for timestamp fields
  • PasswordField - Password input (hidden by default)
  • RelationshipField - Combobox for relationship fields

Field Component Props

All field components share a common set of props:

typescript
interface FieldComponentProps {
  name: string
  value: any
  onChange: (value: any) => void
  label: string
  error?: string
  disabled?: boolean
  required?: boolean
  mode?: 'read' | 'edit'
  // Additional props from field.ui options
}

Example Usage

typescript
import { TextField, SelectField } from '@opensaas/stack-ui/fields'
import { useState } from 'react'

export default function CustomForm() {
  const [title, setTitle] = useState('')
  const [status, setStatus] = useState('draft')

  return (
    <form>
      <TextField
        name="title"
        value={title}
        onChange={setTitle}
        label="Title"
        required
      />

      <SelectField
        name="status"
        value={status}
        onChange={setStatus}
        label="Status"
        options={[
          { label: 'Draft', value: 'draft' },
          { label: 'Published', value: 'published' },
        ]}
      />
    </form>
  )
}

Primitives

Low-level shadcn/ui components for building custom interfaces.

Available Primitives

Form Controls:

  • Input - Text input
  • Textarea - Multi-line text input
  • Label - Form label
  • Button - Button component
  • Checkbox - Checkbox input
  • Select - Dropdown select with full component set (SelectTrigger, SelectContent, SelectItem, etc.)
  • Combobox - Searchable select with autocomplete

Layout:

  • Card, CardHeader, CardContent, CardFooter - Card components
  • Dialog, DialogContent, DialogHeader, DialogFooter - Modal dialogs
  • Popover, PopoverTrigger, PopoverContent - Popover components

Data Display:

  • Table, TableHeader, TableBody, TableRow, TableCell - Table components

Date/Time:

  • Calendar - Calendar component
  • TimePicker - Time picker component
  • DateTimePicker - Combined date and time picker

Example Usage

typescript
import { Card, CardHeader, CardContent, Button, Input } from '@opensaas/stack-ui/primitives'

export default function CustomCard() {
  return (
    <Card>
      <CardHeader>
        <h2>Custom Form</h2>
      </CardHeader>
      <CardContent>
        <Input placeholder="Enter text..." />
        <Button>Submit</Button>
      </CardContent>
    </Card>
  )
}

Customizing Field Components

The UI package uses a component registry pattern that allows you to customize how fields are rendered.

Global Registration

Register a component globally to use it across multiple fields:

typescript
// lib/register-fields.ts
'use client'

import { registerFieldComponent } from '@opensaas/stack-ui'
import { ColorPickerField } from './components/ColorPickerField'

registerFieldComponent('color', ColorPickerField)

Then use it in your config:

typescript
// opensaas.config.ts
import { config, list, text } from '@opensaas/stack-core'

export default config({
  lists: {
    Theme: list({
      fields: {
        primaryColor: text({ ui: { fieldType: 'color' } }),
        secondaryColor: text({ ui: { fieldType: 'color' } }),
      },
    }),
  },
})

Per-Field Override

Pass a component directly for one-off customization:

typescript
// opensaas.config.ts
import { SlugField } from './components/SlugField'

export default config({
  lists: {
    Post: list({
      fields: {
        slug: text({ ui: { component: SlugField } }),
      },
    }),
  },
})

Component Resolution Priority

The FieldRenderer resolves components in the following order:

  1. field.ui.component - Per-field override (highest priority)
  2. field.ui.fieldType - Global registry lookup by custom type
  3. field.type - Default registry lookup by field type

Server Utilities

The @opensaas/stack-ui/server export provides TypeScript types for server action integration.

ServerActionInput

ServerActionInput is the type for the props passed to your server action wrapper. Import it to type the serverAction function you pass to <AdminUI />.

typescript
import type { ServerActionInput } from '@opensaas/stack-ui/server'
import { getContext } 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)
}

There is no getAdminContext helper in this package. The host application is responsible for resolving the session and constructing the context using getContext from the generated .opensaas/context module.

Styling

The UI package includes Tailwind CSS v4 styles. Import them in your root layout:

typescript
// app/layout.tsx
import '@opensaas/stack-ui/styles'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>{children}</body>
    </html>
  )
}

Custom Theming

The UI package is themed through a single set of named CSS custom property tokens (--color-*, --font-*, --radius, --shadow-*) defined once in the package stylesheet. You override them from opensaas.config.ts (ui.theme) or from your own stylesheet — both layers write to the same tokens. Values are any valid CSS colour/length string:

typescript
// opensaas.config.ts
export default config({
  // ...
  ui: {
    theme: {
      preset: 'modern', // 'modern' | 'classic' | 'neon'
      colors: { primary: 'oklch(0.55 0.2 264)' },
      darkColors: { primary: 'oklch(0.62 0.19 264)' },
      radius: 0.5,
    },
  },
})

Dark mode is driven by a data-theme attribute on <html> (with a ThemeToggle and ThemeScript for user control), and component design is customizable through a tokens → classNamesdata-slot → composition ladder.

See the Theming guide for the full token vocabulary, override paths, dark mode, and the customization ladder, and the Theme Presets gallery for modern / classic / neon in light and dark.

Type Safety

All components are fully typed with TypeScript:

  • Context types are inferred from your Prisma client
  • Field props are typed based on field config
  • Form data is validated with react-hook-form + Zod

Performance

The UI package is optimized for performance:

  • Server components by default (AdminUI, standalone components)
  • Client components only where needed (forms, interactive elements)
  • Minimal client-side JavaScript
  • Data fetching on the server reduces bundle size

Examples

Custom Dashboard

Build a custom dashboard using standalone components:

typescript
import { ListTable, ItemCreateForm } from '@opensaas/stack-ui/standalone'
import { Card, CardHeader, CardContent } from '@opensaas/stack-ui/primitives'
import { getContext } from '@/.opensaas/context'
import { getSession } from '@/lib/auth'

export default async function CustomDashboard() {
  const session = await getSession()
  const context = await getContext(session ?? undefined)

  return (
    <div className="grid gap-4 p-4">
      <Card>
        <CardHeader>
          <h2>Recent Posts</h2>
        </CardHeader>
        <CardContent>
          <ListTable
            listKey="Post"
            context={context}
            columns={['title', 'status', 'createdAt']}
          />
        </CardContent>
      </Card>

      <Card>
        <CardHeader>
          <h2>Create New Post</h2>
        </CardHeader>
        <CardContent>
          <ItemCreateForm
            listKey="Post"
            context={context}
            onSuccess={(item) => {
              console.log('Created:', item.id)
            }}
          />
        </CardContent>
      </Card>
    </div>
  )
}

Custom Field Component

Create a custom field component:

typescript
// components/ColorPickerField.tsx
'use client'

import { useState } from 'react'
import type { FieldComponentProps } from '@opensaas/stack-ui/fields'

export function ColorPickerField({
  name,
  value,
  onChange,
  label,
  error,
  required,
}: FieldComponentProps) {
  return (
    <div>
      <label>
        {label}
        {required && <span className="text-red-500">*</span>}
      </label>
      <input
        type="color"
        name={name}
        value={value || '#000000'}
        onChange={(e) => onChange(e.target.value)}
      />
      {error && <p className="text-red-500">{error}</p>}
    </div>
  )
}

Then register it:

typescript
// lib/register-fields.ts
'use client'

import { registerFieldComponent } from '@opensaas/stack-ui'
import { ColorPickerField } from '@/components/ColorPickerField'

registerFieldComponent('color', ColorPickerField)

Related Packages

  • @opensaas/stack-core - Core configuration and access control
  • @opensaas/stack-auth - Authentication with Better-auth
  • @opensaas/stack-tiptap - Rich text editor field type

See Also