Skip to content

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.

┌────────────────────────────────────────────────────────────────┐
│ 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.

LayerTechnologyPurpose
RoutingTanStack RouterType-safe client-side routing
DataTanStack QueryServer state, caching, mutations
UIKumoAccessible components (Base UI + Tailwind)
TablesTanStack TableSorting, filtering, pagination
FormsReact Hook Form + ZodValidation matching server schema
IconsPhosphorConsistent iconography
EditorTipTapRich text editing (Portable Text)

The admin mounts at /_emdash/admin/ and uses client-side routing:

PathScreen
/Dashboard
/content/:collectionContent list
/content/:collection/:idContent editor
/content/:collection/newNew entry
/mediaMedia library
/content-typesSchema builder (admin only)
/menusNavigation menus
/widgetsWidget areas
/taxonomiesCategory/tag management
/settingsSite settings
/plugins/:pluginId/*Plugin pages

The admin doesn’t hardcode knowledge of collections or plugins. Instead, it fetches a manifest from the server:

GET /_emdash/api/manifest

Response:

{
"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
  1. 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

The admin communicates exclusively through REST APIs:

MethodEndpointPurpose
GET/api/content/:collectionList entries
POST/api/content/:collectionCreate entry
GET/api/content/:collection/:idGet entry
PUT/api/content/:collection/:idUpdate entry
DELETE/api/content/:collection/:idSoft delete entry
GET/api/content/:collection/:id/revisionsList revisions
POST/api/content/:collection/:id/preview-urlGenerate preview URL
MethodEndpointPurpose
GET/api/schemaExport full schema
GET/api/schema/collectionsList collections
POST/api/schema/collectionsCreate collection
PUT/api/schema/collections/:slugUpdate collection
DELETE/api/schema/collections/:slugDelete collection
POST/api/schema/collections/:slug/fieldsAdd field
PUT/api/schema/collections/:slug/fields/:fieldUpdate field
DELETE/api/schema/collections/:slug/fields/:fieldDelete field
MethodEndpointPurpose
GET/api/mediaList media items
POST/api/media/upload-urlGet signed upload URL
POST/api/media/:id/confirmConfirm upload complete
DELETE/api/media/:idDelete media item
GET/api/media/file/:keyServe media file
EndpointPurpose
/api/settingsSite settings (GET/POST)
/api/menus/*Navigation menus
/api/widget-areas/*Widget management
/api/taxonomies/*Taxonomy terms
/api/admin/plugins/*Plugin state

All list endpoints use cursor-based pagination:

{
"items": [...],
"nextCursor": "eyJpZCI6IjAxSjEyMzQ1NiJ9"
}

Fetch the next page:

GET /api/content/posts?cursor=eyJpZCI6IjAxSjEyMzQ1NiJ9

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 mount under /_emdash/admin/plugins/:pluginId/*:

// @emdash-cms/plugin-seo/src/admin.tsx
export const pages = [
{
path: "settings",
component: SEOSettingsPage,
label: "SEO Settings",
},
];

Renders at: /_emdash/admin/plugins/seo/settings

Plugins can add widgets to the dashboard:

export const widgets = [
{
id: "seo-overview",
component: SEOWidget,
title: "SEO Overview",
size: "half", // "full" | "half" | "third"
},
];

The admin shell route enforces authentication via Astro middleware:

// Simplified middleware logic
export 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.

Different roles see different parts of the admin:

RoleVisible Sections
EditorDashboard, 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.

The content editor generates forms dynamically based on field definitions:

// Simplified editor rendering
function 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 TypeWidget
stringText input
textTextarea
numberNumber input
booleanToggle switch
datetimeDate/time picker
selectDropdown
multiSelectMulti-select
portableTextTipTap editor
imageMedia picker
referenceEntry picker

Portable Text fields use TipTap (ProseMirror) for editing:

User types → TipTap (ProseMirror JSON) → Save → Portable Text (DB)
Load → Portable Text (DB) → TipTap (ProseMirror JSON) → Display

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

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:

  1. Request upload URLPOST /api/media/upload-url 2. Upload directly — Client PUTs file to signed URL (R2/S3) 3. Confirm uploadPOST /api/media/:id/confirm 4. Server extracts metadata — Dimensions, MIME type, etc.

This approach bypasses Workers body size limits and provides real upload progress.