Getting Started
Admin Panel
The EmDash admin panel is a React single-page application embedded in your Astro site. It provides a complete content management interface for editors and administrators.
Architecture Overview
Section titled “Architecture Overview”┌────────────────────────────────────────────────────────────────┐│ Astro Shell ││ /_emdash/admin/[...path].astro ││ ││ ┌──────────────────────────────────────────────────────────┐ ││ │ React SPA │ ││ │ │ ││ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ ││ │ │ TanStack │ │ TanStack │ │ Kumo │ │ ││ │ │ Router │ │ Query │ │ Components │ │ ││ │ └─────────────┘ └─────────────┘ └─────────────────┘ │ ││ │ │ ││ │ ┌────────────────────────────────────────────────────┐ │ ││ │ │ REST API Client │ │ ││ │ │ /_emdash/api/* │ │ ││ │ └────────────────────────────────────────────────────┘ │ ││ └──────────────────────────────────────────────────────────┘ │└────────────────────────────────────────────────────────────────┘The admin is a “big island” React app. Astro handles the shell and authentication; all navigation and rendering inside the admin is client-side.
Technology Stack
Section titled “Technology Stack”| Layer | Technology | Purpose |
|---|---|---|
| Routing | TanStack Router | Type-safe client-side routing |
| Data | TanStack Query | Server state, caching, mutations |
| UI | Kumo | Accessible components (Base UI + Tailwind) |
| Tables | TanStack Table | Sorting, filtering, pagination |
| Forms | React Hook Form + Zod | Validation matching server schema |
| Icons | Phosphor | Consistent iconography |
| Editor | TipTap | Rich text editing (Portable Text) |
Route Structure
Section titled “Route Structure”The admin mounts at /_emdash/admin/ and uses client-side routing:
| Path | Screen |
|---|---|
/ | Dashboard |
/content/:collection | Content list |
/content/:collection/:id | Content editor |
/content/:collection/new | New entry |
/media | Media library |
/content-types | Schema builder (admin only) |
/menus | Navigation menus |
/widgets | Widget areas |
/taxonomies | Category/tag management |
/settings | Site settings |
/plugins/:pluginId/* | Plugin pages |
Manifest-Driven UI
Section titled “Manifest-Driven UI”The admin doesn’t hardcode knowledge of collections or plugins. Instead, it fetches a manifest from the server:
GET /_emdash/api/manifestResponse:
{ "collections": [ { "slug": "posts", "label": "Blog Posts", "labelSingular": "Post", "icon": "file-text", "supports": ["drafts", "revisions", "preview"], "fields": [ { "slug": "title", "type": "string", "required": true }, { "slug": "content", "type": "portableText" } ] } ], "plugins": [ { "id": "audit-log", "label": "Audit Log", "adminPages": [{ "path": "history", "label": "Audit History" }], "widgets": [{ "id": "recent-activity", "title": "Recent Activity" }] } ], "taxonomies": [{ "name": "category", "label": "Categories", "hierarchical": true }], "version": "abc123"}The admin builds its navigation, forms, and editors entirely from this manifest. Benefits:
- Schema changes appear immediately — No admin rebuild needed
- Plugin UI integrates automatically — Pages and widgets from the manifest
- Type safety at the boundary — Zod schemas stay on the server
Data Flow
Section titled “Data Flow”- Admin SPA loads — TanStack Router initializes 2. Fetch manifest — TanStack Query caches collection/plugin metadata 3. Build navigation — Sidebar generated from manifest 4. User navigates — Client-side routing, no page reload 5. Fetch data — TanStack Query requests content from REST APIs 6. Render forms — Field editors generated from manifest field descriptors 7. Submit changes — Mutations via TanStack Query, optimistic updates 8. Server validates — Zod schemas on the server, errors returned as JSON
REST API Endpoints
Section titled “REST API Endpoints”The admin communicates exclusively through REST APIs:
Content APIs
Section titled “Content APIs”| Method | Endpoint | Purpose |
|---|---|---|
GET | /api/content/:collection | List entries |
POST | /api/content/:collection | Create entry |
GET | /api/content/:collection/:id | Get entry |
PUT | /api/content/:collection/:id | Update entry |
DELETE | /api/content/:collection/:id | Soft delete entry |
GET | /api/content/:collection/:id/revisions | List revisions |
POST | /api/content/:collection/:id/preview-url | Generate preview URL |
Schema APIs
Section titled “Schema APIs”| Method | Endpoint | Purpose |
|---|---|---|
GET | /api/schema | Export full schema |
GET | /api/schema/collections | List collections |
POST | /api/schema/collections | Create collection |
PUT | /api/schema/collections/:slug | Update collection |
DELETE | /api/schema/collections/:slug | Delete collection |
POST | /api/schema/collections/:slug/fields | Add field |
PUT | /api/schema/collections/:slug/fields/:field | Update field |
DELETE | /api/schema/collections/:slug/fields/:field | Delete field |
Media APIs
Section titled “Media APIs”| Method | Endpoint | Purpose |
|---|---|---|
GET | /api/media | List media items |
POST | /api/media/upload-url | Get signed upload URL |
POST | /api/media/:id/confirm | Confirm upload complete |
DELETE | /api/media/:id | Delete media item |
GET | /api/media/file/:key | Serve media file |
Other APIs
Section titled “Other APIs”| Endpoint | Purpose |
|---|---|
/api/settings | Site settings (GET/POST) |
/api/menus/* | Navigation menus |
/api/widget-areas/* | Widget management |
/api/taxonomies/* | Taxonomy terms |
/api/admin/plugins/* | Plugin state |
Pagination
Section titled “Pagination”All list endpoints use cursor-based pagination:
{ "items": [...], "nextCursor": "eyJpZCI6IjAxSjEyMzQ1NiJ9"}Fetch the next page:
GET /api/content/posts?cursor=eyJpZCI6IjAxSjEyMzQ1NiJ9Plugin Admin UI
Section titled “Plugin Admin UI”Plugins can extend the admin with pages and dashboard widgets. The integration generates a virtual module with static imports:
// virtual:emdash/plugin-admins (generated)import * as pluginAdmin0 from "@emdash-cms/plugin-seo/admin";import * as pluginAdmin1 from "@emdash-cms/plugin-analytics/admin";
export const pluginAdmins = { seo: pluginAdmin0, analytics: pluginAdmin1,};Plugin Pages
Section titled “Plugin Pages”Plugin pages mount under /_emdash/admin/plugins/:pluginId/*:
// @emdash-cms/plugin-seo/src/admin.tsxexport const pages = [ { path: "settings", component: SEOSettingsPage, label: "SEO Settings", },];Renders at: /_emdash/admin/plugins/seo/settings
Dashboard Widgets
Section titled “Dashboard Widgets”Plugins can add widgets to the dashboard:
export const widgets = [ { id: "seo-overview", component: SEOWidget, title: "SEO Overview", size: "half", // "full" | "half" | "third" },];Authentication
Section titled “Authentication”The admin shell route enforces authentication via Astro middleware:
// Simplified middleware logicexport async function onRequest({ request, locals }, next) { const session = await getSession(request);
if (request.url.includes("/_emdash/admin")) { if (!session?.user) { return redirect("/_emdash/admin/login"); } locals.user = session.user; }
return next();}The admin SPA itself doesn’t handle login—that’s an Astro page that sets a session cookie.
Role-Based Access
Section titled “Role-Based Access”Different roles see different parts of the admin:
| Role | Visible Sections |
|---|---|
| Editor | Dashboard, assigned collections, media |
| Admin | + Content Types, all collections, settings |
| Developer | + CLI access, generated types |
The manifest endpoint filters collections and features based on the requesting user’s role.
Content Editor
Section titled “Content Editor”The content editor generates forms dynamically based on field definitions:
// Simplified editor renderingfunction ContentEditor({ collection, fields }) { return ( <form> {fields.map((field) => ( <FieldWidget key={field.slug} type={field.type} label={field.label} required={field.required} options={field.options} /> ))} </form> );}Each field type has a corresponding widget:
| Field Type | Widget |
|---|---|
string | Text input |
text | Textarea |
number | Number input |
boolean | Toggle switch |
datetime | Date/time picker |
select | Dropdown |
multiSelect | Multi-select |
portableText | TipTap editor |
image | Media picker |
reference | Entry picker |
Rich Text Editor
Section titled “Rich Text Editor”Portable Text fields use TipTap (ProseMirror) for editing:
User types → TipTap (ProseMirror JSON) → Save → Portable Text (DB)Load → Portable Text (DB) → TipTap (ProseMirror JSON) → DisplayConversion happens at load/save boundaries via portableTextToProsemirror() and prosemirrorToPortableText().
Supported blocks:
- Paragraphs, headings (H1-H6)
- Bullet and numbered lists
- Blockquotes, code blocks
- Images (from media library)
- Links
Unknown blocks from plugins or imports are preserved as read-only placeholders.
Media Library
Section titled “Media Library”The media library provides:
- Grid and list views
- Search and filter by type, date
- Drag-and-drop upload
- Image preview with metadata
- Bulk selection and delete
Uploads use signed URLs for direct client-to-storage upload:
- Request upload URL —
POST /api/media/upload-url2. Upload directly — Client PUTs file to signed URL (R2/S3) 3. Confirm upload —POST /api/media/:id/confirm4. Server extracts metadata — Dimensions, MIME type, etc.
This approach bypasses Workers body size limits and provides real upload progress.