Show navigationHide navigation
Admin routing
The OctoCMS admin (/cms) is mounted via a single catch-all Next.js route that re-exports AdminApp from the package's octocms/admin barrel. The package owns dispatch, per-component Suspense boundaries, and per-component skeletons; the user app only ships three thin re-export files.
User-app surface#
src/app/cms/ layout.tsx re-exports AdminLayout + metadata from octocms/admin [[...path]]/page.tsx re-exports AdminApp from octocms/admin error.tsx re-exports AdminError from octocms/admin (use client)
That's it. Three files, each a one- or two-line re-export. octocms init scaffolds them; octocms update keeps them in sync.
Package surface#
The octocms/admin barrel exports:
| Export | Used in | What it does |
|---|---|---|
AdminApp | [[...path]]/page.tsx | Async catch-all RSC: awaits params, dispatches by segment. No Suspense in the dispatcher (navigation hold-over; client pages use TanStack Query block skeletons). |
AdminLayout | layout.tsx | Shared chrome (TopHeader, providers, theme). Layout-level — survives sub-route navigation. |
metadata | layout.tsx | Next.js <head> metadata. |
AdminError | error.tsx | Drop-in error.tsx body — a one-liner that renders AdminErrorView. |
AdminErrorView | (advanced) | The shared error card for users who want a custom error.tsx shape. |
Streaming model#
AdminApp is an async server component. The dispatcher looks like:
tsxexport async function AdminApp({ params }) { const { path } = await params; const segments = path ?? []; if (segments[0] === 'content' && segments.length === 2) { const [, type] = segments; return <CollectionPage params={Promise.resolve({ type })} key={type} />; } // … other branches: thin `*Page` shells + client components with block skeletons }
Two things make navigation smooth:
- No
SuspenseinAdminApp(no route-level skeleton fallbacks in the dispatcher). When the user navigates between sub-routes, Next.js keeps the previous segment visible until the new tree resolves;await paramsruns without a package-level shimmer fallback. - Granular loading: client pages (
DashboardContent,EditPost, media, model, …) use TanStack Query and render co-located block skeletons while queries are pending.
The result: chrome (header, providers, theme) stays mounted; the main slot transitions from previous content → per-surface block skeletons → new content. No dispatcher-level Suspense in AdminApp.
The client Layout (octocms/components/Layout/Layout.tsx) wraps the catch-all children in <Suspense fallback={<RouteMainSlotSkeleton />}> so TopHeader stays mounted and the main slot shows a route-matched page skeleton (via resolveAdminRouteSkeleton(pathname)) instead of empty space while the RSC segment resolves; TanStack Query block skeletons then take over inside the route once the client bundle mounts.
Thin server shells (*Page in octocms/admin/pages/) use next/dynamic with the same page skeleton as the route picker for chunk-load fallback. Unknown admin paths fall back to NeutralPageSkeleton (AdminApp catch-all miss).
Skeletons — two loading tiers#
1. Full-page fallback (bootstrap, main-slot Suspense, dynamic chunk load) — entire Page chrome is shimmer via PageChromeSkeleton, composed with route-level exports:
octocms/components/Layout/skeletons/PageChromeSkeleton.tsx # mirrors Page.tsx DOM octocms/components/Layout/skeletons/routeSkeletons.tsx # resolveAdminRouteSkeleton + re-exports octocms/components/Dashboard/skeletons/DashboardPageSkeleton.tsx octocms/components/MediaManager/skeletons/MediaManagerPageSkeleton.tsx octocms/components/EditPost/skeletons/EditPostPageSkeleton.tsx octocms/components/ContentModel/skeletons/ContentModelListPageSkeleton.tsx octocms/components/ContentModel/skeletons/ContentTypeDetailPageSkeleton.tsx octocms/components/MediaAsset/skeletons/MediaAssetPageSkeleton.tsx
2. Block fallback (TanStack Query isPending && !data) — real Page header/actions where possible, block skeletons in slots:
octocms/components/Dashboard/skeletons/LeftPanelSkeleton.tsx octocms/components/Dashboard/skeletons/ContentTableSkeleton.tsx octocms/components/MediaManager/skeletons/MediaLeftPanelSkeleton.tsx octocms/components/MediaManager/skeletons/MediaGridSkeleton.tsx octocms/components/EditPost/skeletons/EntryFormSkeleton.tsx … (see each feature's skeletons/ subfolder)
Cross-cutting primitives (ShimmerBlock, ShimmerRow) and small blocks (FormFieldSkeleton, CardSkeleton) live in octocms/components/skeletons/. AdminBootstrapSkeleton (bootstrap only) is exported from octocms/components/skeletons/index.ts.
Trade-offs vs. per-segment routes#
Compared to one Next.js route per page (which was tried in a previous version):
- Lost: automatic
<Link>prefetch for sub-paths (Next.js can't prefetch unknown URL shapes inside a catch-all). The admin is rarely navigated via prefetched links so this is acceptable. - Lost: per-segment layouts (e.g. a content sidebar that survives entry navigation). The current admin doesn't use any.
- Gained: 3 user files instead of 17+. Package-owned dispatch — all routing changes ship through the npm package, not user-app churn.
- Gained: route-aware skeleton picker — cross-route navigation no longer flashes the content-dashboard layout on media/model/entry routes.
See error-handling.md for the full story. The short version:
error.tsx(re-exportingAdminError) catches anything thrown during render of the catch-all tree.- Risky interactive client components (MDXEditor, Composer, Message, MediaManager, ContentTypeDetail, FormReferenceField) are additionally wrapped in
<ErrorBoundary>fromoctocms/components/ErrorBoundary/ErrorBoundaryso a single component crash doesn't blank the whole route.
Migrating from prior versions#
If you ran an earlier version of OctoCMS that scaffolded the deep-import catch-all (export { AdminApp as default } from 'octocms/admin/AdminApp'), run:
bashnpx octocms update
It will:
- Recognise the legacy deep-import shape and rewrite the catch-all + layout to use the new
octocms/adminbarrel. - Scaffold any of the three target files that aren't already present.
- Leave any user-customised file alone.
The migration is idempotent — re-running on an already-migrated tree is a no-op.