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

to navigateto open

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.

Start from the previous lesson's code

The About page: its title and kicker above a wide photo of a designer with folded arms in a bright room beside a pool

The blocks

The page intro takes over from the hero: a title and a short line below it.

apps/backend/blocks/PageIntro.ts
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:

apps/backend/blocks/Image.ts
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.

apps/backend/blocks/Prose.ts
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:

Terminal
rm apps/backend/blocks/Hero.ts

Pages allows the new blocks, and gains a switch for the line above the footer:

apps/backend/collections/Pages.tslines 10-16
    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:

Terminal
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.md

The About text is rich text, a list of paragraphs:

apps/backend/seed/content.ts
// 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:

apps/backend/boot/seed.ts
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:

Terminal
rm -rf apps/backend/.data apps/backend/.uploads
pnpm dev

The components

Each new block gets its component:

apps/web/components/blocks/PageIntro.tsx
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>
  );
}
apps/web/components/blocks/Image.tsx
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>
  );
}
apps/web/components/blocks/Prose.tsx
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:

Terminal
rm apps/web/components/blocks/Hero.tsx

The page names the new components, and draws the line above the footer when a page asks for it:

apps/web/app/[[...slug]]/page.tsx
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:

apps/web/app/[[...slug]]/not-found.tsxlines 4-7
import { Image } from '../../components/blocks/Image';
import { PageIntro } from '../../components/blocks/PageIntro';
import { Portfolio } from '../../components/blocks/Portfolio';
import { Prose } from '../../components/blocks/Prose';
apps/web/app/[[...slug]]/not-found.tsxlines 18-19
      <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:

apps/web/app/styles/site.css
/* 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.

Terminal
git clone --branch lesson-04-about-page https://github.com/ohnejs/rayleigh-next rayleigh
Browse on GitHub