A 100% API-compatible drop-in replacement for React Admin using ShadCN UI with native mongo.do integration.
- Overview
- Design Philosophy
- Package Structure
- Component Architecture
- Testing Strategy
- TDD Workflow
- Technology Stack
- Development Phases
Shadmin provides the exact same API surface as React Admin while replacing Material UI with modern ShadCN components. The key insight is that React Admin's architecture is excellent—the problem is MUI's dated aesthetics and heavy runtime. We preserve the API, replace the rendering layer.
┌─────────────────────────────────────────────────────────────────────────────┐
│ Your App │
│ <Admin dataProvider={mondoProvider} authProvider={...}> │
│ <Resource name="users" list={UserList} edit={UserEdit} /> │
│ </Admin> │
├─────────────────────────────────────────────────────────────────────────────┤
│ SHADMIN LAYER │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Components (React Admin API-compatible, ShadCN-rendered) │ │
│ │ • Admin, Resource, List, Datagrid, Create, Edit, Show │ │
│ │ • Field components (TextField, DateField, ReferenceField...) │ │
│ │ • Input components (TextInput, DateInput, SelectInput...) │ │
│ │ • Layout (Sidebar, AppBar, Breadcrumb) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Contexts & Hooks (identical to React Admin) │ │
│ │ • RecordContext, ListContext, FormContext, ResourceContext │ │
│ │ • useRecordContext, useDataProvider, useNotify, useRedirect │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ DataProvider Interface (9 methods, identical signature) │ │
│ │ • getList, getOne, getMany, getManyReference │ │
│ │ • create, update, updateMany, delete, deleteMany │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────────────────┤
│ MONGO.DO DATA LAYER │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ mondoDataProvider (native integration) │ │
│ │ • Translates DataProvider calls → mongo.do RPC │ │
│ │ • Optimistic updates with change stream sync │ │
│ │ • Connection pooling & request deduplication (built into mongo.do) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────────────────┤
│ CLOUDFLARE EDGE │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ MondoDB Durable Object (SQLite storage) │ │
│ │ • Query translation (MongoDB → SQL) │ │
│ │ • Aggregation pipeline execution │ │
│ │ • Vector search, full-text search │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
Every component, hook, and interface matches React Admin's API exactly:
// Before (React Admin)
import { Admin, Resource, List, Datagrid, TextField } from 'react-admin'
// After (Shadmin) - Just change the import!
import { Admin, Resource, List, Datagrid, TextField } from 'shadmin'- Copy-paste ownership: Components live in your codebase
- CSS Variables: Theming via
globals.css, not JSS runtime - Tailwind v4: Modern utility-first styling
- Radix/Base UI: Accessible, headless primitives
- Zero-config data layer with
createMondoDataProvider - Real-time updates via change streams
- Vector search and full-text search built-in
- Edge-first deployment on Cloudflare Workers
Every component follows RED → GREEN → REFACTOR:
- RED: Write failing tests first (defines the contract)
- GREEN: Implement minimum code to pass
- REFACTOR: Optimize without changing behavior
packages/
├── shadmin/ # Main package
│ ├── src/
│ │ ├── components/
│ │ │ ├── core/ # Admin, Resource, CoreAdminContext
│ │ │ ├── list/ # List, Datagrid, Pagination, Filters
│ │ │ ├── detail/ # Create, Edit, Show
│ │ │ ├── form/ # SimpleForm, TabbedForm, Toolbar
│ │ │ ├── field/ # TextField, DateField, ReferenceField...
│ │ │ ├── input/ # TextInput, SelectInput, ReferenceInput...
│ │ │ ├── layout/ # Layout, Sidebar, AppBar, Menu
│ │ │ ├── button/ # SaveButton, DeleteButton, EditButton...
│ │ │ └── auth/ # Login, Logout, AuthRequired
│ │ │
│ │ ├── contexts/ # RecordContext, ListContext, FormContext...
│ │ ├── hooks/ # useRecordContext, useGetList, useNotify...
│ │ ├── providers/ # DataProvider implementations
│ │ ├── ui/ # ShadCN components (copied/customized)
│ │ ├── types/ # TypeScript interfaces
│ │ └── utils/ # Shared utilities
│ │
│ └── package.json
│
├── shadmin-db/ # mongo.do integration
│ ├── src/
│ │ ├── data-provider.ts # createMondoDataProvider
│ │ ├── auth-provider.ts # createMondoAuthProvider
│ │ └── change-stream.ts # Real-time updates
│ └── package.json
│
└── create-shadmin/ # CLI scaffolding
└── src/
| Component | Purpose | ShadCN Foundation |
|---|---|---|
<Admin> |
Root provider orchestration | SidebarProvider |
<Resource> |
CRUD route registration | React Router |
<List> |
List view wrapper | Card |
<Datagrid> |
Data table | Data Table (TanStack) |
<Create> |
Create form wrapper | Card |
<Edit> |
Edit form wrapper | Card |
<Show> |
Read-only view | Card |
<SimpleForm> |
Form container | Field primitive |
<TabbedForm> |
Tabbed form | Tabs + Field |
| Field | Renders As |
|---|---|
TextField |
<span> |
NumberField |
Formatted via Intl.NumberFormat |
DateField |
Formatted via Intl.DateTimeFormat |
BooleanField |
Badge or icon |
EmailField |
mailto: link |
UrlField |
External link |
ChipField |
ShadCN Badge |
ReferenceField |
Fetches + renders related record |
ArrayField |
Iterator with children |
FunctionField |
Custom render function |
ImageField |
AspectRatio + img |
| Input | ShadCN Foundation |
|---|---|
TextInput |
Input + Field |
NumberInput |
Input type="number" |
DateInput |
DatePicker |
SelectInput |
Select |
AutocompleteInput |
Combobox |
RadioButtonGroupInput |
RadioGroup |
CheckboxGroupInput |
Checkbox array |
BooleanInput |
Switch |
ReferenceInput |
Fetches choices + wraps child |
ArrayInput |
Dynamic form array |
RichTextInput |
Tiptap editor |
FileInput |
File upload |
| Tool | Purpose |
|---|---|
| Vitest | Unit/integration tests |
| React Testing Library | Component testing |
| Playwright | E2E testing |
| Storybook 8 | Component development & documentation |
| Chromatic | Visual regression testing |
Tests are co-located with source files:
src/components/field/
├── TextField.tsx
├── TextField.spec.tsx # Unit tests
├── TextField.stories.tsx # Storybook stories
└── index.ts
// src/test-utils/testDataProvider.ts
const defaultTestDataProvider: DataProvider = {
getList: async () => { throw new Error('getList not implemented') },
getOne: async () => { throw new Error('getOne not implemented') },
// ... other methods
}
export const testDataProvider = (
overrides?: Partial<DataProvider>
): DataProvider => ({
...defaultTestDataProvider,
...overrides,
})Usage:
const dataProvider = testDataProvider({
getOne: jest.fn().mockResolvedValue({ data: { id: 1, name: 'Test' } })
})// src/test-utils/AdminContext.tsx
export const TestAdminContext = ({
children,
dataProvider = testDataProvider(),
authProvider,
}: {
children: React.ReactNode
dataProvider?: DataProvider
authProvider?: AuthProvider
}) => (
<AdminContext
dataProvider={dataProvider}
authProvider={authProvider}
i18nProvider={defaultI18nProvider}
>
{children}
</AdminContext>
)// src/test-utils/TestMemoryRouter.tsx
export const TestMemoryRouter = ({
children,
initialEntries = ['/'],
}: {
children: React.ReactNode
initialEntries?: string[]
}) => {
const router = createMemoryRouter([
{ path: '*', element: children }
], { initialEntries })
return <RouterProvider router={router} />
}// TextField.spec.tsx
describe('<TextField />', () => {
it('renders the field value', () => {
render(
<TestAdminContext>
<RecordContextProvider value={{ id: 1, title: 'Hello' }}>
<TextField source="title" />
</RecordContextProvider>
</TestAdminContext>
)
expect(screen.getByText('Hello')).toBeInTheDocument()
})
it('renders emptyText when value is null', () => {
render(
<TestAdminContext>
<RecordContextProvider value={{ id: 1, title: null }}>
<TextField source="title" emptyText="N/A" />
</RecordContextProvider>
</TestAdminContext>
)
expect(screen.getByText('N/A')).toBeInTheDocument()
})
})// useGetList.spec.tsx
describe('useGetList', () => {
const TestComponent = ({ callback }: { callback: (result: any) => void }) => {
const result = useGetList('posts', { pagination: { page: 1, perPage: 10 } })
callback(result)
return null
}
it('fetches list data', async () => {
const callback = vi.fn()
const dataProvider = testDataProvider({
getList: vi.fn().mockResolvedValue({
data: [{ id: 1, title: 'Post 1' }],
total: 1,
}),
})
render(
<TestAdminContext dataProvider={dataProvider}>
<TestComponent callback={callback} />
</TestAdminContext>
)
await waitFor(() => {
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
data: [{ id: 1, title: 'Post 1' }],
total: 1,
})
)
})
})
})// TextInput.spec.tsx
describe('<TextInput />', () => {
it('renders with initial value', () => {
render(
<TestAdminContext>
<ResourceContextProvider value="posts">
<SimpleForm defaultValues={{ title: 'Hello' }} onSubmit={vi.fn()}>
<TextInput source="title" />
</SimpleForm>
</ResourceContextProvider>
</TestAdminContext>
)
const input = screen.getByLabelText('Title') as HTMLInputElement
expect(input.value).toBe('Hello')
})
it('validates required field on submit', async () => {
render(
<TestAdminContext>
<ResourceContextProvider value="posts">
<SimpleForm onSubmit={vi.fn()}>
<TextInput source="title" validate={required()} />
</SimpleForm>
</ResourceContextProvider>
</TestAdminContext>
)
fireEvent.click(screen.getByText('Save'))
await waitFor(() => {
expect(screen.getByText('Required')).toBeInTheDocument()
})
})
})// tests/e2e/create.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Create Page', () => {
test('creates a new record', async ({ page }) => {
await page.goto('/posts/create')
await page.fill('input[name="title"]', 'New Post')
await page.fill('textarea[name="body"]', 'Post content')
await page.click('button[type="submit"]')
await expect(page).toHaveURL(/\/posts$/)
await expect(page.locator('.notification')).toContainText('Created')
})
})// TextField.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { TextField } from './TextField'
import { TestAdminContext, RecordContextProvider } from '@/test-utils'
const meta: Meta<typeof TextField> = {
title: 'Fields/TextField',
component: TextField,
decorators: [
(Story) => (
<TestAdminContext>
<RecordContextProvider value={{ id: 1, title: 'Hello World' }}>
<Story />
</RecordContextProvider>
</TestAdminContext>
),
],
}
export default meta
type Story = StoryObj<typeof TextField>
export const Default: Story = {
args: {
source: 'title',
},
}
export const WithLabel: Story = {
args: {
source: 'title',
label: 'Post Title',
},
}
export const EmptyValue: Story = {
decorators: [
(Story) => (
<TestAdminContext>
<RecordContextProvider value={{ id: 1, title: null }}>
<Story />
</RecordContextProvider>
</TestAdminContext>
),
],
args: {
source: 'title',
emptyText: 'No title',
},
}import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test-utils/setup.ts'],
include: ['src/**/*.spec.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['**/*.stories.tsx', '**/test-utils/**'],
},
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
})import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
// Cleanup after each test
afterEach(() => {
cleanup()
})
// Mock window.scrollTo
vi.stubGlobal('scrollTo', vi.fn())
// Fail on console.error (strict mode)
const originalError = console.error
console.error = (...args: any[]) => {
originalError.apply(console, args)
throw new Error('Test failed due to console.error')
}import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'pnpm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
})Each component follows this cycle:
Write failing tests that define the component contract:
// TextField.spec.tsx
describe('<TextField />', () => {
it('renders the value from source prop', () => {
// This test WILL FAIL - TextField doesn't exist yet
render(
<TestAdminContext>
<RecordContextProvider value={{ id: 1, name: 'Test' }}>
<TextField source="name" />
</RecordContextProvider>
</TestAdminContext>
)
expect(screen.getByText('Test')).toBeInTheDocument()
})
})Write minimum code to make tests pass:
// TextField.tsx
export const TextField = ({ source }: { source: string }) => {
const record = useRecordContext()
const value = record?.[source]
return <span>{value}</span>
}Improve code quality without changing behavior:
// TextField.tsx
export interface TextFieldProps {
source: string
label?: string
emptyText?: string
className?: string
}
export const TextField = ({
source,
label,
emptyText = '',
className,
}: TextFieldProps) => {
const record = useRecordContext()
const value = get(record, source) // Support nested paths
if (value == null && emptyText) {
return <span className={cn('text-muted-foreground', className)}>{emptyText}</span>
}
return <span className={className}>{String(value)}</span>
}| Category | Technology |
|---|---|
| Runtime | React 19, TypeScript 5.6 |
| Build | Vite, tsup |
| Styling | Tailwind CSS v4, CSS Variables |
| Components | ShadCN UI, Radix UI |
| Forms | react-hook-form, Zod |
| Tables | TanStack Table |
| Routing | React Router v7 |
| State | TanStack Query |
| Testing | Vitest, RTL, Playwright, Storybook |
| Visual Testing | Chromatic |
| Package Manager | pnpm workspaces |
| CI/CD | GitHub Actions |
- Project setup (Vite, TypeScript, monorepo)
- Testing infrastructure (Vitest, Playwright, Storybook, Chromatic)
- CI/CD pipeline
- Context system (RecordContext, ListContext, FormContext...)
- Hook system (useGetList, useCreate, useNotify...)
- Admin & Resource components
- mongo.do DataProvider
- List wrapper
- Datagrid with TanStack Table
- Pagination
- Filters
- Create/Edit/Show wrappers
- SimpleForm & TabbedForm
- Toolbar & buttons
- Basic fields (Text, Number, Date, Boolean)
- Link fields (Email, URL)
- Reference fields
- Complex fields (Array, Function, Rich)
- Basic inputs (Text, Number, Password)
- Date/Time inputs
- Selection inputs (Select, Autocomplete, Radio, Checkbox)
- Reference inputs
- Complex inputs (Array, RichText, File)
- Layout with Sidebar
- AppBar & navigation
- Theming system
- Auth components
All tasks are tracked in the beads issue system. Run bd ready to see available work.
- Epics: High-level feature areas (e.g., "EPIC: Datagrid Component")
- Tasks: Individual work items following TDD pattern
RED:- Write failing testsGREEN:- Implement to pass testsREFACTOR:- Optimize and clean upStorybook:- Create storiesVisual test:- Add visual snapshotsE2E:- Add Playwright tests
bd ready # Show work ready to start
bd list --type=epic # List all epics
bd show <id> # View issue details
bd update <id> --status=in_progress # Claim work
bd close <id> # Mark complete- Pick an issue from
bd ready - Follow TDD: RED → GREEN → REFACTOR
- Add Storybook stories
- Ensure all tests pass
- Submit PR
Built for the edge. Designed for AI. Styled with taste.