Lesson 01
Project setup
Create Rayleigh as an ohne CMS and a Nuxt website in one repository, then give the site the template's fonts, layout and page markup.
pnpm create ohne@0.5.0 rayleigh --web nuxtIt creates a monorepo: the CMS in apps/backend and the website in apps/web. Answer its questions, then start both apps:
cd rayleigh
pnpm devOpen the dashboard at http://localhost:9000 and create your admin account. Open the Home page under Pages: the website sits beside the form and changes as you type. See The live editor.


Fonts and styles
Copy the fonts, the favicon and the stylesheets from the lesson's tag, and remove the starter's stylesheet:
curl -sL https://github.com/ohnejs/rayleigh-nuxt/archive/refs/tags/lesson-01-project-setup.tar.gz \
| tar -xzf - --strip-components=1 \
rayleigh-nuxt-lesson-01-project-setup/apps/web/public \
rayleigh-nuxt-lesson-01-project-setup/apps/web/app/assets/styles
rm apps/web/app/assets/site.cssThe stylesheets are plain CSS. site.css imports the others:
/* Rayleigh's styles, in plain CSS. The order matters: tokens first, then the rules that use them. */
@import './fonts.css';
@import './tokens.css';
@import './base.css';
@import './layout.css';Load site.css, title the site and preload the two fonts every page uses:
export default defineNuxtConfig({
compatibilityDate: '2026-10-01',
// `@ohnejs/nuxt` reads each page from the CMS, fills the `<head>` and joins the live preview.
// See https://ohne.dev/docs/cms/frameworks#nuxt
modules: ['@ohnejs/nuxt'],
// Your API in development. `NUXT_OHNE_API` replaces it when the server starts.
ohne: { api: 'http://localhost:9001' },
css: ['~/assets/styles/site.css'],
app: {
head: {
// The title of a page that has none of its own, like the error page.
// A page from the CMS gets its own title, like "About | Rayleigh".
title: 'Rayleigh',
link: [
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
// The fonts of the body text and the headings start loading before the stylesheet asks for them.
{
rel: 'preload',
href: '/fonts/lato-400.woff2',
as: 'font',
type: 'font/woff2',
crossorigin: '',
},
{
rel: 'preload',
href: '/fonts/poppins-500.woff2',
as: 'font',
type: 'font/woff2',
crossorigin: '',
},
],
},
},
});The layout
The header and the footer wrap every page. Their parts start as placeholders, which Header and footer fills from the CMS.
<script setup lang="ts">
// Centers its content at the site's width, with the gutter at each side.
</script>
<template>
<div class="container">
<slot />
</div>
</template><script setup lang="ts">
// Drawn in `currentColor`, so it takes the color of the link around it.
</script>
<template>
<svg class="logo" fill="none" width="40" height="40" viewBox="0 0 40 40" aria-hidden="true">
<path
d="M32.0608 35.9556C36.884 32.3041 40 26.5159 40 20C40 13.8167 37.194 8.28885 32.786 4.6202C33.8193 6.49511 34.336 8.57807 34.336 10.8691C34.336 13.6739 33.5312 16.2312 31.9215 18.541C30.3521 20.8096 27.9175 22.3564 24.6177 23.1813L32.0608 35.9556Z"
fill="currentColor"
/>
<path
d="M5 6.77089C1.88818 10.2966 0 14.9278 0 20C0 25.0722 1.88818 29.7035 5 33.2291V6.77089Z"
fill="currentColor"
/>
<path
d="M11.8813 38.2835C14.3623 39.3869 17.1096 40 20 40C22.0013 40 23.9339 39.7061 25.757 39.159L17.1328 23.8H11.8813V38.2835Z"
fill="currentColor"
/>
<path
d="M19.4869 18.1698H11.8813V3.75397H19.4869C22.1026 3.75397 24.0543 4.3933 25.3421 5.67195C26.6298 6.90937 27.2736 8.64174 27.2736 10.8691C27.2736 13.0964 26.6097 14.87 25.2817 16.1899C23.994 17.5098 22.0624 18.1698 19.4869 18.1698Z"
fill="currentColor"
/>
</svg>
</template><template>
<nav class="menu">
<ul class="menu__list">
<li class="menu__link">Menu</li>
</ul>
</nav>
</template><template>
<button type="button">Dark / Light mode</button>
</template><template>
<header class="container header">
<NuxtLink to="/" class="header__home" aria-label="Rayleigh">
<Logo />
</NuxtLink>
<Menu />
<DarkModeSwitch />
</header>
</template><template>
<footer class="container footer">
<p class="footer__copyright">© 2026 Rayleigh. Built with ohne.</p>
</footer>
</template><template>
<div>
<Header />
<slot />
<Footer />
</div>
</template>The page and its blocks
One page renders every path from the CMS. See Frameworks. It drops its own heading, since each block brings one:
<script setup lang="ts">
// The `ohne` middleware asks the CMS what this path shows, once, before the page renders.
// A redirect or a missing page answers with its own status code, so this page only renders content.
definePageMeta({ middleware: 'ohne' });
// Each block renders its own heading, so a page adds no `<h1>` of its own.
// A path no page answers shows your CMS page with the slug `404`, when you create one.
const page = useOhnePage();
</script>
<template>
<main v-if="page?.kind === 'page' && page.collection === 'Pages'" class="main">
<OhneBlocks :blocks="page.record.content" />
</main>
<main v-else-if="page?.kind === 'notFound' && page.page" class="main">
<OhneBlocks :blocks="page.page.content" />
</main>
</template>Each block has a component named like it. Put both in a Container, and make the Hero's heading the page's h1:
<script setup lang="ts">
// `ohne.ts` holds the types the CMS writes, so `fields` matches the block in `apps/backend/blocks/`.
import type { Blocks } from '~~/ohne';
// `OhneBlocks` in the page renders this component for every `Hero` block, by its name.
// Keep one root element: the live preview outlines the block through it.
defineProps<{ fields: Blocks['Hero'] }>();
</script>
<template>
<Container>
<h1>{{ fields.heading }}</h1>
<p v-if="fields.lead"><OhneRichText :value="fields.lead" inline /></p>
</Container>
</template><script setup lang="ts">
import type { Blocks } from '~~/ohne';
// A click on a link to one of your pages moves there without a full page load.
defineProps<{ fields: Blocks['Prose'] }>();
</script>
<template>
<Container>
<OhneRichText :value="fields.body" class="prose" />
</Container>
</template>The error page
Give a missing page the same layout:
<script setup lang="ts">
import type { NuxtError } from '#app';
// Nuxt shows this page for a path no page in the CMS answers, and when the API fails.
// A failure skips the layout, since the header and the footer may read the API that just failed.
const props = defineProps<{ error: NuxtError }>();
const missing = computed(() => props.error.statusCode === 404);
</script>
<template>
<NuxtLayout :name="missing ? 'default' : false">
<main class="main">
<Container>
<div class="page-intro">
<h1>{{ missing ? 'Page not found' : 'Something went wrong' }}</h1>
<p class="page-intro__kicker"><NuxtLink to="/">Back to the home page</NuxtLink></p>
</div>
</Container>
</main>
</NuxtLayout>
</template>Reload http://localhost:3000. The starter's Home page now reads in Rayleigh's fonts, between a placeholder header and the footer. The next lesson, Portfolio block, adds the galleries.
Checkpoint
The code at this lesson
Every lesson ends at a tag. Clone it to start from here, or compare it with yours.
git clone --branch lesson-01-project-setup https://github.com/ohnejs/rayleigh-nuxt rayleigh