Lesson 04
About page
Give Rayleigh an About page: new blocks for a page intro, a wide image and rich text, and the Next components that render them.
import { defineBlock, field } from 'ohnejs';
export default defineBlock({
description: 'The title that opens a page, with a short line below it',
fields: {
title: field('text'),
kicker: field('text', { nullable: true }),
},
dashboard: { icon: 'h-1' },
});The image block shows one wide image:
import { defineBlock, field } from 'ohnejs';
export default defineBlock({
description: 'One wide image',
fields: {
image: field('image', { minWidth: 1440 }),
},
dashboard: { icon: 'photo' },
});The prose block limits its editor to what Rayleigh has styles for. See Rich text.
import { defineBlock, field } from 'ohnejs';
export default defineBlock({
description: 'Formatted text: headings, lists and links',
fields: {
// Rich text is stored as JSON, never HTML, and links to a page follow it when its slug changes.
// The options limit the editor's toolbar to what the design has styles for.
// See https://ohne.dev/docs/database/rich-text
body: field('richText', {
elements: ['h2', 'h3', 'ul'],
marks: ['strong', 'em'],
links: ['Pages'],
}),
},
dashboard: { icon: 'align-left' },
});The hero goes:
rm apps/backend/blocks/Hero.tsPages allows the new blocks, and gains a switch for the line above the footer:
footerBorder: field('boolean', {
default: false,
display: 'switch',
description: 'Draws a line above the footer.',
}),
// The page's content, as a list of blocks. Each name here is a file in `blocks/`.
content: field('blocks', { allow: ['PageIntro', 'Portfolio', 'Image', 'Prose'] }),The content
Copy the About image and its credits from this lesson's tag:
curl -sL https://github.com/ohnejs/rayleigh-next/archive/refs/tags/lesson-04-about-page.tar.gz \
| tar -xzf - --strip-components=1 \
rayleigh-next-lesson-04-about-page/apps/backend/seed/images/other \
rayleigh-next-lesson-04-about-page/apps/backend/seed/CREDITS.mdThe About text is rich text, a list of paragraphs:
// The demo copy of Rayleigh, written as rich text.
// Rich text is a list of blocks, so a paragraph and a heading are small objects.
// See https://ohne.dev/docs/database/rich-text
/**
* A paragraph of plain text.
*/
function p(text: string) {
return { kind: 'paragraph' as const, content: [{ text }] };
}
/**
* The two galleries of the home page, each image with its caption.
*/
export const galleries = [
{
title: 'Interior design',
directory: 'interior',
items: [
['modern-armchair', 'Modern armchair'],
['contemporary-coffee-table', 'Contemporary coffee table'],
['sleek-lamp', 'Sleek lamp'],
['elegant-vase', 'Elegant vase'],
['simple-bookshelf', 'Simple bookshelf'],
['stylish-sofa', 'Stylish sofa'],
],
},
{
title: 'Exterior design',
directory: 'exterior',
items: [
['luna-bench', 'Luna bench'],
['aurora-lamp', 'Aurora lamp'],
['serenity-fountain', 'Serenity fountain'],
['zephyr-sculpture', 'Zephyr sculpture'],
['harmony-seating', 'Harmony seating'],
['mystic-path', 'Mystic path'],
],
},
] as const;
/**
* The text of the About page.
*/
export const about = [
p(
"Welcome to my world of transforming spaces with art! I am an interior/exterior artist with a passion for creating visually stunning environments. With my expertise in both interior and exterior design, I have the ability to capture the essence of a space and elevate it to new heights. Whether it's revamping a room's interior or rejuvenating the exterior of a building, I take pride in my ability to bring life and personality to every project. From selecting the perfect color palette to incorporating unique textures and patterns, I believe that each element plays a crucial role in crafting a space that truly reflects the client's vision.",
),
p(
'I have a keen eye for detail and a deep understanding of spatial aesthetics. I strive to create balanced compositions that evoke emotions, inspire creativity, and enhance the surrounding atmosphere. By carefully blending form and function, I aim to create harmonious spaces that not only look breathtaking but also fulfill practical needs. My artistic journey began at a young age, fueled by my fascination with colors, textures, and the interplay of light and shadows. Over the years, I have honed my skills through formal education and hands-on experience, working on a diverse range of projects, from residential homes to commercial establishments.',
),
p(
"Being an avid traveler, I draw inspiration from various cultures, architectural styles, and natural landscapes. This allows me to infuse a distinct global perspective into my designs, resulting in truly unique and captivating spaces. I strongly believe in the power of collaboration and value open communication with my clients. By understanding their preferences, needs, and aspirations, I am able to create personalized and tailored designs that exceed expectations. Thank you for visiting my portfolio. I look forward to the opportunity to work with you and turn your interior or exterior space into a work of art. Let's embark on a creative journey together!",
),
];The seed opens the home page with a page intro, adds the About page, and links it from the menu:
import { openAsBlob } from 'node:fs';
import { hook, query, useEnv } from 'ohnejs';
import { putUpload, updateUpload } from 'ohnejs/uploads';
import { about, galleries } from '../seed/content.ts';
/**
* Uploads one image from `seed/images/` to the media library, as an editor would, and gives it its alt text.
* Returns the upload's `UUID`, which an image field stores.
*/
async function upload(path: string, description: string): Promise<string> {
const [directory, name] = path.includes('/') ? path.split('/') : ['', path];
const file = await openAsBlob(new URL(`../seed/images/${path}`, import.meta.url));
const { UUID } = await putUpload({ directory, name, body: file.stream(), size: file.size });
await updateUpload(UUID, { description });
return UUID;
}
// Boot files run before the schema sync creates the tables, so the seed waits for `schema:synced`.
// It fires on every start. See https://ohne.dev/docs/project/boot
hook('schema:synced', async () => {
if (useEnv().get('NODE_ENV') === 'production') return;
// Only an empty site gets the demo, so the seed never touches your edits.
if (await query('Pages').exists()) return;
const logo = await upload('logo.svg', 'Rayleigh');
const portfolio = [];
for (const { title, directory, items } of galleries) {
const gallery = [];
for (const [name, caption] of items) {
gallery.push({ image: await upload(`${directory}/${name}.webp`, caption), caption });
}
portfolio.push({ block: 'Portfolio' as const, fields: { title, gallery } });
}
const home = await query('Pages').createOrThrow({
title: 'Homepage',
// `index` is the slug of the home page, shown at `/`.
slug: 'index',
status: 'published',
content: [
{
block: 'PageIntro',
fields: {
title: 'Welcome to Rayleigh',
kicker: 'A minimalistic portfolio template made with ohne.',
},
},
...portfolio,
],
});
const aboutPage = await query('Pages').createOrThrow({
title: 'About',
slug: 'about',
status: 'published',
footerBorder: true,
content: [
{
block: 'PageIntro',
fields: {
title: 'About',
kicker: 'Unveiling the artistry of interior and exterior design.',
},
},
{
block: 'Image',
fields: {
image: await upload(
'other/about.webp',
'A designer with folded arms in a bright open room beside a pool',
),
},
},
{ block: 'Prose', fields: { body: about } },
],
});
// A singleton always has its one record, so the seed updates it instead of creating it.
await query('Settings').update({
logo,
menu: [
{ label: 'Portfolio', link: { collection: 'Pages', record: home.UUID } },
{ label: 'About', link: { collection: 'Pages', record: aboutPage.UUID } },
],
copyright: '© 2026 Rayleigh. Built with ohne.',
social: [
{
icon: 'github',
link: { url: 'https://github.com/ohnejs', newTab: true },
title: 'ohne on GitHub',
},
{
icon: 'website',
link: { url: 'https://ohne.dev/examples/rayleigh', newTab: true },
title: 'Rayleigh on ohne.dev',
},
],
});
// `Site` comes with the CMS: the site's name, which every page title ends with.
await query('Site').update({
name: 'Rayleigh',
description: 'Rayleigh is a minimalistic portfolio template made with ohne.',
});
});Stop pnpm dev, delete the data and start again, then create your admin account once more:
rm -rf apps/backend/.data apps/backend/.uploads
pnpm devThe components
Each new block gets its component:
import type { Block } from '../../ohne';
import { Container } from '../Container';
export function PageIntro({ fields }: Block<'PageIntro'>) {
return (
<Container>
<div className="page-intro">
<h1>{fields.title}</h1>
{fields.kicker && <p className="page-intro__kicker">{fields.kicker}</p>}
</div>
</Container>
);
}import type { Block } from '../../ohne';
import { Container } from '../Container';
// An image field may be empty, and then the block shows nothing.
export function Image({ fields: { image } }: Block<'Image'>) {
if (!image) return null;
return (
<Container className="content-block">
<img
className="image-block"
src={image.url}
width={image.width ?? undefined}
height={image.height ?? undefined}
alt={image.description ?? ''}
decoding="async"
/>
</Container>
);
}import { OhneRichText } from '@ohnejs/next/client';
import type { Block } from '../../ohne';
import { Container } from '../Container';
// The block's editor offers only headings, lists, bold, italic and links, the styles `prose.css` has.
// A click on a link to one of your pages moves there without a full page load.
export function Prose({ fields }: Block<'Prose'>) {
return (
<Container className="content-block">
<OhneRichText value={fields.body} className="prose" />
</Container>
);
}The hero's component goes too:
rm apps/web/components/blocks/Hero.tsxThe page names the new components, and draws the line above the footer when a page asks for it:
import { OhneBlocks } from '@ohnejs/next';
import { Image } from '../../components/blocks/Image';
import { PageIntro } from '../../components/blocks/PageIntro';
import { Portfolio } from '../../components/blocks/Portfolio';
import { Prose } from '../../components/blocks/Prose';
import { ohne } from '../../lib/ohne';
// The title, description, share tags and canonical link, from the page's `seo` in the CMS.
export const generateMetadata = ohne.metadata;
// One page renders every path, `/` included, by asking the CMS which page the path shows.
// A redirect or a missing page answers with its own status code, so this page only renders content.
export default async function Page() {
const { page } = await ohne.page();
// Each block renders its own heading, so a page adds no `<h1>` of its own.
return (
<main className={page.record.footerBorder ? 'main main--bordered' : 'main'} lang={page.locale}>
{/* Name a component for every block. A block the CMS adds fails `pnpm typecheck` until it has one. */}
<OhneBlocks
blocks={page.record.content}
components={{ PageIntro, Portfolio, Image, Prose }}
/>
</main>
);
}The not-found page does the same:
import { Image } from '../../components/blocks/Image';
import { PageIntro } from '../../components/blocks/PageIntro';
import { Portfolio } from '../../components/blocks/Portfolio';
import { Prose } from '../../components/blocks/Prose'; <main className={page.footerBorder ? 'main main--bordered' : 'main'}>
<OhneBlocks blocks={page.content} components={{ PageIntro, Portfolio, Image, Prose }} />Styles
Rich text gets its own stylesheet, and the blocks and the layout gain the new parts:
/* 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';
@import './components.css';
@import './blocks.css';
@import './prose.css';Open http://localhost:3000/about, or follow About in the menu.
Next, Blog adds posts, their authors and a page that lists them.
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-04-about-page https://github.com/ohnejs/rayleigh-next rayleigh
