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:

ExportUsed inWhat it does
AdminApp[[...path]]/page.tsxAsync catch-all RSC: awaits params, dispatches by segment. No Suspense in the dispatcher (navigation hold-over; client pages use TanStack Query block skeletons).
AdminLayoutlayout.tsxShared chrome (TopHeader, providers, theme). Layout-level — survives sub-route navigation.
metadatalayout.tsxNext.js <head> metadata.
AdminErrorerror.tsxDrop-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:

tsx
export 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:

  1. No Suspense in AdminApp (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 params runs without a package-level shimmer fallback.
  2. 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-exporting AdminError) 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> from octocms/components/ErrorBoundary/ErrorBoundary so 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:

bash
npx octocms update

It will:

  1. Recognise the legacy deep-import shape and rewrite the catch-all + layout to use the new octocms/admin barrel.
  2. Scaffold any of the three target files that aren't already present.
  3. Leave any user-customised file alone.

The migration is idempotent — re-running on an already-migrated tree is a no-op.