Serving the Decap admin shell
The admin UI requires a compiled browser bundle — compile the Laika backend and Decap CMS together with esbuild, then serve the resulting files as static assets.
Why not esm.sh / import maps? esm.sh re-bundles packages on the fly but does not fully resolve deep
export *barrel chains. The@laikacms/decap/decap-cms-backend-laikasubpath depends on symbols re-exported through several barrel layers (e.g.DocumentsCompatibilityDate) that esm.sh's bundler drops, so the admin silently fails to load. esbuild resolves all transitive imports at build time and produces a self-contained bundle with no runtime CDN dependency.
Install build dependencies
npm install @laikacms/decap @laikacms/decap-cms decap-cms-app esbuild --save-dev@laikacms/decap-cms is the scoped Decap CMS fork that provides the @laikacms/decap-cms/lib/util, /lib/auth, /ui-default, and /core subpaths required by the Laika backend at bundle time. Without it the esbuild step will fail with "Could not resolve" errors.
Create the admin entry point
// admin/index.ts
import { createLaikaBackend } from '@laikacms/decap/decap-cms-backend-laika';
import CMS from 'decap-cms-app';
const LaikaBackend = createLaikaBackend();
CMS.registerBackend('laika', LaikaBackend);
CMS.init({
config: {
backend: {
name: 'laika',
base_url: 'http://localhost:3000', // URL where your LaikaCMS API is running; required
api_root: '/api/decap',
dev_token: 'dev-secret-change-me', // dev-only: bypasses OAuth2; remove for production
},
media_folder: 'uploads',
public_folder: '/uploads',
collections: [
// same collections array as your server-side decapConfig
],
},
});
base_urlis required. Without it, Decap cannot locate the Laika API and the admin shows "Missing required configuration: base_url and app_id are required". Set it to the origin where yourdecapApihandler runs (e.g.http://localhost:3000locally, your public URL in production).
dev_tokenlets the Decap admin authenticate without a full OAuth2 flow during development — any non-empty string works as long as yourauthenticateAccessTokencallback accepts it. Remove this field before deploying to production and wire a real OAuth2 / JWT validator instead (see Production auth withdecap-oauth2below).Alternatively, omit
config:fromCMS.init()and place the backend settings inadmin/config.ymlnext toindex.html— Decap loads it automatically at startup. See quickstart-fs-decap for a completeconfig.ymlexample.
Create the HTML shell
<!-- admin/index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Admin</title>
</head>
<body>
<!-- esbuild compiles admin/index.ts → admin/bundle.js -->
<script src="bundle.js"></script>
</body>
</html>Build and serve
# Build the bundle (re-run after editing admin/index.ts)
npx esbuild admin/index.ts --bundle --outfile=admin/bundle.js --format=iife --target=es2020
# Terminal 1 — LaikaCMS API (with CORS if admin runs on a different port)
npm start
# Terminal 2 — admin UI
npx serve admin/ -l 5000Open http://localhost:5000 to access the Decap CMS admin.
CORS: when the admin (
npx serve -l 5000) and the API (:3000) are on different origins, addcors: { origins: ['http://localhost:5000'] }to yourdecapApi(...)call. Without it the browser blocks every request with a CORS error. In production, serve the admin and API from the same origin to avoid the need for CORS. See quickstart-fs-decap for a complete working example.
For a framework server (Hono, Express, Astro, etc.), serve the admin/ directory as static files and mount it before any catch-all API handler:
// Hono + @hono/node-server
import { serveStatic } from '@hono/node-server/serve-static';
app.use('/admin/*', serveStatic({ root: './admin' }));
// Express
import express from 'express';
app.use('/admin', express.static('admin'));
// Then mount the API catch-all after
app.all('/api/decap/*', c => api.fetch(c.req.raw));For full control (custom widgets, the Decap React tree) you can instead render a React island:
// src/components/DecapAdmin.tsx (a React island)
import { App } from '@laikacms/decap-cms/app';
import DecapCmsCore, { DecapCmsProvider } from '@laikacms/decap-cms/core';
import DEFAULT_WIDGET_STRING from '@laikacms/decap-cms/widgets/string';
import { createLaikaBackend } from '@laikacms/decap/decap-cms-backend-laika';
// …other widgets…
import { decapConfig } from '~/lib/decap-config.ts';
DecapCmsCore.registerBackend('laika', createLaikaBackend());
DecapCmsCore.registerWidget(DEFAULT_WIDGET_STRING);
// …etc…
export default function DecapAdmin() {
const cfg = {
...decapConfig,
backend: {
...decapConfig.backend,
base_url: window.location.origin,
},
};
return (
<DecapCmsProvider config={cfg}>
<App />
</DecapCmsProvider>
);
}The authenticateAccessToken validator you pass to decapApi(...) decides who may call the API. For local development you can accept a pre-shared token; for production, validate a real session/JWT (or front the whole thing with the decap-oauth2 server below).