Frameworks
The whole loop in Nuxt, Next, React, and plain HTML.
Each recipe is the whole loop for one framework: read the page, handle redirects and 404s, fill the <head>, and join the live preview. Nuxt and Next have a package that runs it for you. React and any other server build it from @ohnejs/client.
The TypeScript recipes read their types from ohne.ts, for a Pages collection whose content holds Hero and Text blocks.
Nuxt
@ohnejs/nuxt is a Nuxt module that runs the whole loop. Install it with the client:
pnpm add @ohnejs/nuxt @ohnejs/clientList the module and name your API:
export default defineNuxtConfig({
modules: ['@ohnejs/nuxt'],
ohne: { api: 'https://api.example.com' },
});- Only the server reads
ohne.api, so it may be a private address. The browser reads through your site's own/_ohneroutes. NUXT_OHNE_APIreplaces it when the server starts, so one build serves every environment.- The module types every page from the
ohne.tsnext tonuxt.config.ts.
One catch-all page renders every path. An app/app.vue hides it unless it renders <NuxtPage />, so delete the one Nuxt's starter ships:
<!-- app/pages/[...slug].vue -->
<script setup lang="ts">
definePageMeta({ middleware: 'ohne' });
const page = useOhnePage();
</script>
<template>
<main v-if="page?.kind === 'page' && page.collection === 'Pages'">
<h1>{{ page.record.title }}</h1>
<OhneBlocks :blocks="page.record.content" />
</main>
<main v-else-if="page?.kind === 'notFound' && page.page">
<h1>{{ page.page.title }}</h1>
<OhneBlocks :blocks="page.page.content" />
</main>
</template>The ohne middleware resolves the path and its query once, on the server:
- A page fills
useOhnePage(). Checkingcollectionnarrowsrecordto that collection's fields. - A redirect answers with its own code, and renders nothing.
- A miss answers
404with your404page, and a failing API502.
The module also fills the <head>, keeps a preview out of caches and search engines, joins the live preview, and serves /sitemap.xml and /robots.txt. A robots.txt in public/ wins, so delete the one Nuxt's starter ships.
OhneBlocks renders each block with the component of the same name in app/components/blocks/. A block component takes the block's fields as fields:
<!-- app/components/blocks/Hero.vue -->
<script setup lang="ts">
import type { Blocks } from '~~/ohne';
defineProps<{ fields: Blocks['Hero'] }>();
</script>
<template>
<section>
<h2>{{ fields.heading }}</h2>
<OhneRichText :value="fields.body" />
</section>
</template>Text.vue is the same with Blocks['Text'], rendering only <OhneRichText :value="fields.body" />.
- The root element gets
data-ohne-block, so the editor can select the block. Keep one root. OhneRichTextsends clicks on links to your pages throughnavigateTo.OhneLinkrenders alinkfield as aNuxtLink.nuxt typechecknames a block that has no component.
A layout or a component reads site data with useOhneRead and useOhneList:
<!-- app/layouts/default.vue -->
<script setup lang="ts">
const settings = await useOhneRead('Settings');
</script>
<template>
<div>
<nav>
<OhneLink v-for="item in settings.menu" :key="item.label" :link="item.link">
{{ item.label }}
</OhneLink>
</nav>
<slot />
<footer>{{ settings.copyright }}</footer>
</div>
</template>- Records read in the current page's locale unless you pass
locale. - A failed read shows the error page with a
502, as a failed page does. So the ref always holds the record, and the template needs no?..
Options passed as a getter read again when they change, like a page number from the query:
const route = useRoute();
const posts = await useOhneList('Posts', () => ({ page: Number(route.query.page) || 1 }));A form sends its record with useOhne().create. The browser posts to your site, and your site passes the create on to your API:
<!-- app/components/ContactForm.vue -->
<script setup lang="ts">
const ohne = useOhne();
const sent = ref(false);
const errors = ref<Partial<Record<string, string>>>({});
async function send(event: Event) {
const form = new FormData(event.target as HTMLFormElement);
const field = (name: string) => String(form.get(name));
const message = { name: field('name'), email: field('email'), message: field('message') };
const headers = { 'recaptcha-token': field('g-recaptcha-response') };
const result = await ohne.create('Messages', message, { headers });
if (result.ok) sent.value = true;
else errors.value = result.errors;
}
</script>
<template>
<p v-if="sent">Thanks!</p>
<form v-else @submit.prevent="send">
<input name="name" />
<input name="email" type="email" />
<p v-if="errors.email">{{ errors.email }}</p>
<textarea name="message" />
<button>Send</button>
</form>
</template>Your site passes on only the body, the visitor's language and their address. A header your spam check reads must be named in ohne.headers:
export default defineNuxtConfig({
modules: ['@ohnejs/nuxt'],
ohne: { api: 'https://api.example.com', headers: ['recaptcha-token'] },
});nuxt typecheck runs vue-tsc, which needs TypeScript 6. Install both with pnpm add -D typescript@6 vue-tsc. The module's README covers layouts and reading other paths.
Next
@ohnejs/next runs the whole loop in the App Router. Install it with the client:
pnpm add @ohnejs/next @ohnejs/clientCreate the connection once:
import { createOhneNext } from '@ohnejs/next';
import type { CMS } from '../ohne';
export const ohne = createOhneNext<CMS>({ api: process.env.OHNE_API ?? 'http://localhost:9001' });OHNE_API is your API's address. The server reads it when it runs, so one build serves every environment, and it may be a private address.
Every page request passes through the proxy:
import { ohne } from './lib/ohne';
export const proxy = ohne.proxy;
export const config = { matcher: '/((?!_next/).*)' };It hands each page its path and query, keeps a preview out of caches and search engines, and serves the editor's script under /_ohne/, so keep the matcher covering that.
One catch-all page renders every path, / included, so delete app/page.tsx:
import { OhneBlocks } from '@ohnejs/next';
import { Hero } from '../../components/hero';
import { Text } from '../../components/text';
import { ohne } from '../../lib/ohne';
export const generateMetadata = ohne.metadata;
export default async function Page() {
const { page } = await ohne.page();
return (
<main lang={page.locale}>
<h1>{page.record.title}</h1>
<OhneBlocks blocks={page.record.content} components={{ Hero, Text }} />
</main>
);
}ohne.page()resolves the address. A permanent redirect answers308, a temporary one307, and a miss404.- Remove
cacheComponentsandpartialPrefetchingfromnext.config.ts: with them, redirects and misses answer200. ohne.metadatafills the title, description, share tags, robots, canonical andhreflanglinks.componentsmust name a component for every block, so a block the CMS adds fails the typecheck.
A miss renders the not-found page beside it, with your 404 page:
import { OhneBlocks } from '@ohnejs/next';
import { Hero } from '../../components/hero';
import { Text } from '../../components/text';
import { ohne } from '../../lib/ohne';
export default async function NotFound() {
const { resolved } = await ohne.current();
const page = resolved.kind === 'notFound' ? resolved.page : undefined;
return (
<main>
<h1>{page?.title ?? 'Page not found'}</h1>
<OhneBlocks blocks={page?.content} components={{ Hero, Text }} />
</main>
);
}The root layout joins the live preview. Outside the editor's frame, OhnePreview does nothing:
import { OhnePreview } from '@ohnejs/next/client';
export default function Layout({ children }: LayoutProps<'/'>) {
return (
<html lang="en">
<body>
{children}
<OhnePreview />
</body>
</html>
);
}Each block component takes its block:
import { OhneRichText } from '@ohnejs/next/client';
import type { Block } from '../ohne';
export function Hero({ fields }: Block<'Hero'>) {
return (
<section>
<h2>{fields.heading}</h2>
<OhneRichText value={fields.body} />
</section>
);
}text.tsx is the same with Block<'Text'>, rendering only <OhneRichText value={fields.body} />.
OhneBlockswraps each block in a<div>withdata-ohne-block, so the editor can select it.OhneRichTextsends clicks on links to your pages through Next's router.OhneLinkrenders alinkfield as Next'sLink.
A layout or a component reads site data with ohne.read and ohne.list:
import { OhneLink } from '@ohnejs/next/client';
import { ohne } from '../../lib/ohne';
export default async function SiteLayout({ children }: LayoutProps<'/[[...slug]]'>) {
const settings = await ohne.read('Settings');
return (
<>
<nav>
{settings.menu.map((item) => (
<OhneLink key={item.label} link={item.link}>
{item.label}
</OhneLink>
))}
</nav>
{children}
<footer>{settings.copyright}</footer>
</>
);
}- Reads are cached like the page's, and dropped with it by the webhook route.
- Records read in your default locale unless you pass
locale.
Only a page receives searchParams. A block reads the query with ohne.searchParams() instead, like a page number for its list:
const page = Number((await ohne.searchParams()).get('page')) || 1;
const posts = await ohne.list('Posts', { order: ['-publishedAt'], page });A layout must not read the query: Next keeps a layout as first rendered when only the query changes.
A form sends its record from a Server Action with ohne.create:
'use server';
import { ohne } from '../../lib/ohne';
import type { Creatable } from '../../ohne';
export async function sendMessage(message: Creatable['Messages'], token: string) {
return ohne.create('Messages', message, { headers: { 'recaptcha-token': token } });
}A Client Component calls it and shows each field's error beside its input:
'use client';
import { useState } from 'react';
import { sendMessage } from '../app/contact/actions';
export function ContactForm() {
const [errors, setErrors] = useState<Partial<Record<string, string>>>({});
const [sent, setSent] = useState(false);
async function send(form: FormData) {
const field = (name: string) => String(form.get(name));
const message = { name: field('name'), email: field('email'), message: field('message') };
const result = await sendMessage(message, field('g-recaptcha-response'));
if (result.ok) setSent(true);
else setErrors(result.errors);
}
if (sent) return <p>Thanks!</p>;
return (
<form action={send}>
<input name="name" />
<input name="email" type="email" />
{errors.email && <p>{errors.email}</p>}
<textarea name="message" />
<button>Send</button>
</form>
);
}A create is never cached, and it passes the visitor's address and language on.
Two route handlers serve the sitemap and robots.txt at your site's root:
import { ohne } from '../../lib/ohne';
export const GET = ohne.sitemap;import { ohne } from '../../lib/ohne';
export const GET = ohne.robots;The package's README covers caching, with a route for webhooks that drops the cache when content changes.
React
A page rendered in the browser fetches itself, and takes each new page from onData:
import { connect, createOhne } from '@ohnejs/client';
import { useEffect, useState } from 'react';
const api = import.meta.env.VITE_OHNE_API;
const ohne = createOhne({ api });
const token = ohne.token(location.href);
export function Page() {
const [page, setPage] = useState();
useEffect(() => {
ohne.resolve(location.pathname + location.search, { token }).then((next) => {
if (next.kind === 'redirect') location.replace(next.to);
else setPage(next);
});
if (token) return connect({ api, onData: setPage }).dispose;
}, []);
useEffect(() => {
if (page?.kind === 'page') document.title = page.seo.title;
}, [page]);
if (!page) return null;
if (page.kind === 'notFound') return <h1>Not found</h1>;
return (
<main>
<h1>{page.record.title}</h1>
{page.record.content?.map((block) => (
<section key={block.UUID} data-ohne-block={block.UUID}>
{block.fields.heading}
</section>
))}
</main>
);
}A rich text field renders through one component. The recipe has no router, so a link to another page loads it like any other link:
import { richTextToHTML } from '@ohnejs/client';
export function RichText({ value }) {
return <div dangerouslySetInnerHTML={{ __html: richTextToHTML(value) }} />;
}The page renders a field with <RichText value={block.fields.body} />.
Search engines read a page rendered in the browser poorly. Prefer a server-rendered framework for a public site.
Any server
Without a framework, render the HTML on the server. renderHead writes the <head> tags, and richTextToHTML renders a rich text field. previewScript loads the client at the end of <body>; on each change it fetches the page again and swaps the <body> in:
import { createServer } from 'node:http';
import { createOhne, escapeHTML, richTextToHTML } from '@ohnejs/client';
const ohne = createOhne({ api: process.env.OHNE_API });
createServer(async (request, response) => {
const token = ohne.token(request.url);
const page = await ohne.resolve(request.url, { token });
if (page.kind === 'redirect') {
response.writeHead(page.code, { location: page.to }).end();
return;
}
if (page.kind === 'notFound') {
response
.writeHead(404, { 'content-type': 'text/html; charset=utf-8' })
.end('<h1>Not found</h1>');
return;
}
const blocks = (page.record.content ?? []).map(
(block) =>
`<section data-ohne-block="${block.UUID}">
<h2>${escapeHTML(block.fields.heading)}</h2>
${richTextToHTML(block.fields.body)}
</section>`,
);
response.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
...(token ? ohne.previewHeaders() : {}),
});
response.end(`<!doctype html>
<html>
<head>${ohne.renderHead(page, { token })}</head>
<body>
<h1>${escapeHTML(page.record.title)}</h1>
${blocks.join('')}
${token ? ohne.previewScript() : ''}
</body>
</html>`);
}).listen(3000);