Search pages, headings and code across the docs, examples and tutorials.

to navigateto open
CMS

The CMS

Install the layer, map paths to collections, and edit pages live.

@ohnejs/cms turns your ohne app into the CMS for a website. You build the website with any framework: Nuxt, Next, React, or plain HTML. Your editors edit its pages in the dashboard, with the live website beside the form.

Install it, and list it as a layer after ohnejs/uploads:

Terminal
pnpm add @ohnejs/cms
ohne.config.ts
import { defineConfig } from 'ohnejs';

export default defineConfig({
  layers: ['ohnejs/base', 'ohnejs/uploads', '@ohnejs/cms'],
  cms: {
    site: 'https://example.com',
    routes: {
      Pages: '/[...slug]',
    },
  },
});

site is where your website lives. The SITE_URL environment variable overrides it, so each environment can point at its own website.

routes says which paths show which collection. A collection named there needs the page fields, which come next.

Pages

pageFields() adds the fields a collection with a route needs:

  • slug - the part of the path that finds the record. No two records share one. See the slug type.
  • status - draft or published.
  • publishedAt and expiresAt - an optional window the page shows in.
  • seo - the page's title, description, and image for search engines. See SEO and redirects.

publishedScope is the matching read scope: a visitor reads only published records inside their window.

collections/Pages.ts
import { pageFields, publishedScope } from '@ohnejs/cms';
import { defineCollection, field } from 'ohnejs';

export default defineCollection({
  fields: {
    title: field('text'),
    ...pageFields(),
    content: field('blocks', { allow: ['Hero', 'Text'] }),
  },
  api: {
    read: { public: true, access: publishedScope },
    create: true,
    update: true,
    delete: true,
  },
});

content holds the page's blocks. Each block it allows is a file in blocks/:

blocks/Hero.ts
import { defineBlock, field } from 'ohnejs';

export default defineBlock({
  fields: {
    heading: field('text'),
    image: field('image'),
    body: field('richText'),
  },
});
blocks/Text.ts
import { defineBlock, field } from 'ohnejs';

export default defineBlock({
  fields: {
    body: field('richText'),
  },
});

Anyone reads published pages. Creating, saving, and deleting need the matching capabilities, like collection.Pages.update.

An editor with the cms.drafts capability reads every record, drafts included.

For a collection with translations, pass pageFields({ translatable: true }). Each locale then has its own slug and SEO.

Routes

A route is a path pattern:

  • [slug] matches one segment: /blog/[slug] matches /blog/hello.
  • [...slug] matches several: /[...slug] matches /about and /about/team, whose slug is about/team.
  • A pattern with no parameter, like /contact, is for a singleton: it shows its one record.

Under /[...slug], these slugs are special:

  • index is the home page. It shows at /, and /index redirects there.
  • 404 is the page your website shows when nothing else matches, /404 included.

A slug must fit the route it shows under:

  • Under [slug] it holds no /, so a/b is refused there.
  • A translatable slug fits its own locale's route, and a shared one fits every locale's.
  • A page whose slug no longer fits, like a/b after you change [...slug] to [slug], has no address there until you change its slug.

Routes per locale

With several locales, give a route per locale. Here Posts is a second collection with the page fields, like Pages:

ohne.config.ts
import { defineConfig } from 'ohnejs';

export default defineConfig({
  layers: ['ohnejs/base', 'ohnejs/uploads', '@ohnejs/cms'],
  collections: { locales: ['en', 'de'], defaultLocale: 'en' },
  cms: {
    site: 'https://example.com',
    routes: {
      Pages: '/[...slug]',
      Posts: { en: '/blog/[slug]', de: '/artikel/[slug]' },
    },
  },
});

A single pattern, like the one for Pages, applies to every locale.

The default locale has no prefix, and the others do. Here /blog/hello and /de/artikel/hallo are the same post. Set prefixDefaultLocale: true to prefix every locale.

The live editor

In the dashboard, a record of a collection with a route opens in the live editor:

  • the blocks on the left, as a tree you can drag, copy, and paste,
  • your website in the middle, framed at the record's page,
  • the selected block's fields, or the record's own, on the right.

Every keystroke shows on the website before you save. Click a block on the website to select it, and use its toolbar to move, add, duplicate, or delete it.

The website takes part through a small client. Your website shows how.

Sharing a preview

The Share button makes a link to the page as it looks right now, unsaved changes included. Anyone with the link can see it, no account needed. Later edits do not show in it.

You can share a page you may edit, once it has been saved.

A link lasts as long as you pick, or until an editor of the record revokes it. cms.share sets the choices:

ohne.config.ts
import { defineConfig } from 'ohnejs';

export default defineConfig({
  layers: ['ohnejs/base', 'ohnejs/uploads', '@ohnejs/cms'],
  cms: {
    site: 'https://example.com',
    routes: { Pages: '/[...slug]' },
    share: {
      durations: ['1h', '1d', '14d'],
      default: '1h',
    },
  },
});

Leave out default and the first duration is picked. Without cms.share, the choices are 1h, 1d, 7d, and 30d, with 1d picked first.