Admin Panel

On this page

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

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

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)

Route Structure

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

Manifest-Driven UI

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

Data Flow

  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

REST API Endpoints

The admin communicates exclusively through REST APIs:

Content 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

Schema APIs

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

Media APIs

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

Other APIs

EndpointPurpose
/api/settingsSite settings (GET/POST)
/api/menus/*Navigation menus
/api/widget-areas/*Widget management
/api/taxonomies/*Taxonomy terms
/api/admin/plugins/*Plugin state

Pagination

All list endpoints use cursor-based pagination:

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

Fetch the next page:

GET /api/content/posts?cursor=eyJpZCI6IjAxSjEyMzQ1NiJ9

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

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

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

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.

Role-Based Access

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.

Content Editor

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

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) → 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.

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:

  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.

Next Steps