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

to navigateto open

Lesson 05

Blog

Add a blog to Rayleigh: posts and their authors in the CMS, an address for each post, and a paged list that a Next server component renders.

Start from the previous lesson's code

The Blog page listing posts, each with its photo, headline, date, author and a Read more button

Authors and posts

Authors get a collection of their own:

apps/backend/collections/Authors.ts
import { defineCollection, field } from 'ohnejs';

// The people who write the posts.
// Your website reads a post's author through this collection, so anyone may read it.
// `Users` stays private: it holds email addresses and passwords.
export default defineCollection({
  fields: {
    name: field('text'),
  },
  api: { read: 'public', create: true, update: true, delete: true },
  dashboard: { icon: 'feather', recordLabel: 'name' },
});

A post is a page of its own, with an author, an image and a summary. The author field is a reference to one record of Authors:

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

export default defineCollection({
  fields: {
    title: field('text'),
    headline: field('text', {
      nullable: true,
      description: 'Shown above the post. Leave it empty to show the title.',
    }),
    // `publishedAt` from the page fields doubles as the date a post was written on.
    ...pageFields(),
    // A reference to one author. Your website receives the whole author, not only its `UUID`.
    // See https://ohne.dev/docs/cms/website#reading-a-page
    author: field('record', { collection: 'Authors' }),
    image: field('image', { description: 'Shown beside the post on the blog page.' }),
    description: field('text', {
      multiline: true,
      description: 'A short summary, shown on the blog page.',
    }),
    content: field('blocks', { allow: ['Image', 'Prose'] }),
  },
  api: {
    read: { public: true, access: publishedScope },
    create: true,
    update: true,
    delete: true,
  },
  dashboard: { icon: 'pin', recordLabel: 'title' },
});

A post shows at /blog/<slug>, from a second route:

apps/backend/ohne.config.tslines 11-14
    // A post shows at `/blog/<slug>`, and every other path shows a record of `Pages`.
    // The more specific route wins, so `/blog/hello` finds a post before it looks for a page.
    // See https://ohne.dev/docs/cms/setup#routes
    routes: { Pages: '/[...slug]', Posts: '/blog/[slug]' },

A page shows the posts through a block of its own:

apps/backend/blocks/Posts.ts
import { defineBlock } from 'ohnejs';

// A block without fields: your website fills it with the newest posts, three to a page.
export default defineBlock({
  description: 'The newest posts, three to a page',
  fields: {},
  dashboard: { icon: 'list' },
});

Pages allows it:

apps/backend/collections/Pages.tslines 15-16
    // 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', 'Posts'] }),

Rich text may link to a post:

apps/backend/blocks/Prose.tslines 6-13
    // Rich text is stored as JSON, never HTML, and links to a page or a post 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', 'Posts'],
    }),

Settings names the page that lists the posts, so each post can link back to it:

apps/backend/collections/Settings.tslines 42-57
    blogPage: field('link', {
      collections: ['Pages'],
      nullable: true,
      label: 'Blog landing page',
      description: 'The page that lists the posts. Every post links back to it.',
    }),
  },
  dashboard: {
    icon: 'settings',
    layout: [
      {
        tabs: [
          { label: 'Header', fields: ['logo', 'menu'] },
          { label: 'Footer', fields: ['copyright', 'social'] },
          { label: 'Blog', fields: ['blogPage'] },
        ],

The content

Copy the posts, their images and the credits from this lesson's tag:

Terminal
curl -sL https://github.com/ohnejs/rayleigh-next/archive/refs/tags/lesson-05-blog.tar.gz \
  | tar -xzf - --strip-components=1 \
    rayleigh-next-lesson-05-blog/apps/backend/seed/content.ts \
    rayleigh-next-lesson-05-blog/apps/backend/seed/images/blog \
    rayleigh-next-lesson-05-blog/apps/backend/seed/CREDITS.md

The seed adds the Blog page, an author and the posts. It also creates the admin account, so you no longer make one by hand:

apps/backend/boot/seed.ts
import { openAsBlob } from 'node:fs';
import { hook, query, queryUntyped, useEnv, usePrinter } from 'ohnejs';
import { putUpload, updateUpload } from 'ohnejs/uploads';
import { randomToken } from 'ohnejs/utils/crypto';

import { about, galleries, posts } 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 blog = await query('Pages').createOrThrow({
    title: 'Blog',
    slug: 'blog',
    status: 'published',
    footerBorder: true,
    content: [
      { block: 'PageIntro', fields: { title: 'Blog', kicker: 'Inspiration at every turn.' } },
      // The website fills this block with the posts.
      { block: 'Posts', fields: {} },
    ],
  });

  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 } },
    ],
  });

  const author = await query('Authors').createOrThrow({ name: 'Khadgar' });
  for (const { image, body, ...post } of posts) {
    const picture = await upload(`blog/${image}.webp`, post.title);
    await query('Posts').createOrThrow({
      ...post,
      headline: post.title,
      status: 'published',
      author: author.UUID,
      image: picture,
      // What search engines and share cards show for the post.
      seo: { description: post.description, image: picture },
      content: [
        { block: 'Image', fields: { image: picture } },
        { block: 'Prose', fields: { body } },
      ],
    });
  }

  // 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: 'Blog', link: { collection: 'Pages', record: blog.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',
      },
    ],
    blogPage: { collection: 'Pages', record: blog.UUID },
  });
  // `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, with a gallery, an About page and a blog.',
  });

  // The admin account, unless you already created one.
  // Its password is random and printed once, so no install starts with a known admin password.
  if (await queryUntyped('Users').unscoped().exists()) return;
  const password = randomToken(12);
  await query('Users').createOrThrow({
    email: 'admin@example.com',
    password,
    firstName: 'Khadgar',
    roles: ['admin'],
  });
  usePrinter().info(`Seeded \`admin@example.com\` with password \`${password}\``);
});

Stop pnpm dev, delete the data and start again:

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

Sign in to the dashboard as admin@example.com, with the password the terminal prints.

The posts block

Two small components come first: a button, and the line with the date, which the post page shows too.

apps/web/components/Button.tsx
import type { ReactNode } from 'react';

import Link from 'next/link';

export function Button({
  to,
  className,
  children,
}: {
  to: string;
  className?: string;
  children: ReactNode;
}) {
  return (
    <Link href={to} className={className ? `button ${className}` : 'button'}>
      <span className="button__label">{children}</span>
    </Link>
  );
}
apps/web/components/WrittenOn.tsx
// One fixed locale and time zone give the same text on the server and in the browser, so hydration matches.
const format = new Intl.DateTimeFormat('en-US', { dateStyle: 'long', timeZone: 'UTC' });

/**
 * Props for `WrittenOn`.
 */
export interface WrittenOnProps {
  /**
   * When the post went live, as a Unix timestamp in milliseconds, like its `publishedAt`.
   */
  date: number | null;

  /**
   * The post's author.
   */
  author: { name: string } | null;
}

export function WrittenOn({ date, author }: WrittenOnProps) {
  if (date === null) return null;
  return (
    <p className="written-on">
      Written on{' '}
      <time dateTime={new Date(date).toISOString().slice(0, 10)}>{format.format(date)}</time>
      {author && ` by ${author.name}`}
    </p>
  );
}

The block reads the posts with ohne.list. See Frameworks.

apps/web/components/blocks/Posts.tsx
import Link from 'next/link';
import { notFound } from 'next/navigation';

import { ohne } from '../../lib/ohne';
import { Button } from '../Button';
import { Container } from '../Container';
import { WrittenOn } from '../WrittenOn';

// The block has no fields: it lists the newest posts, three to a page, wherever an editor puts it.
// It renders on the server, so it may read the CMS itself.
export async function Posts() {
  // Only a page receives `searchParams`, so a block reads the query through `ohne.searchParams()`.
  const value = (await ohne.searchParams()).get('page');
  const page = value === null ? 1 : Number(value);
  if (!Number.isInteger(page) || page < 1) notFound();

  // `ohne.list` reads one page of the `Posts` collection, as an anonymous visitor would.
  // See https://ohne.dev/docs/cms/website#site-data
  const posts = await ohne.list('Posts', {
    order: ['-publishedAt'],
    perPage: 3,
    page,
    select: ['title', 'headline', 'author', 'image', 'description', 'publishedAt'],
  });
  // A page past the last one answers 404, as a missing page does.
  if (page > posts.lastPage) notFound();

  // The pager links keep the page's own path, so the block works on any page.
  const [here] = (await ohne.current()).address.split('?');
  const pageHref = (n: number) => (n > 1 ? `${here}?page=${n}` : here);

  return (
    <Container className="posts">
      {posts.records.map(
        ({ UUID, path, title, headline, author, image, description, publishedAt }) =>
          // A post the visitor can read always has a page, so it carries its `path`.
          path && (
            <article key={UUID} className="posts__item">
              {image && (
                <img
                  className="posts__image"
                  src={image.url}
                  width={image.width ?? undefined}
                  height={image.height ?? undefined}
                  alt={image.description ?? ''}
                  decoding="async"
                  loading="lazy"
                />
              )}
              <div className="posts__body">
                <h2>
                  <Link href={path}>{headline || title}</Link>
                </h2>
                <WrittenOn date={publishedAt} author={author} />
                {description && (
                  <div className="prose posts__description">
                    <p>{description}</p>
                  </div>
                )}
                <Button to={path}>Read more</Button>
              </div>
            </article>
          ),
      )}
      {posts.lastPage > 1 && (
        <nav className="posts__pager" aria-label="Pages">
          {page > 1 && <Button to={pageHref(page - 1)}>Previous page</Button>}
          {page < posts.lastPage && (
            <Button to={pageHref(page + 1)} className="posts__next">
              Next page
            </Button>
          )}
        </nav>
      )}
    </Container>
  );
}

The post page

A post opens with a link back to the blog:

apps/web/components/BackButton.tsx
import type { Link } from '@ohnejs/client';
import type { ReactNode } from 'react';

import { OhneLink } from '@ohnejs/next/client';

import { IconChevronLeft } from './icon/ChevronLeft';

// The link comes from `Settings`, so an editor picks the page it goes back to.
export function BackButton({ link, children }: { link: Link | null; children: ReactNode }) {
  if (!link) return null;
  return (
    <OhneLink link={link} className="back-button">
      <IconChevronLeft />
      {children}
    </OhneLink>
  );
}

The catch-all page renders posts as well as pages. It checks which collection the record comes from:

apps/web/app/[[...slug]]/page.tsx
import { OhneBlocks } from '@ohnejs/next';

import { BackButton } from '../../components/BackButton';
import { Image } from '../../components/blocks/Image';
import { PageIntro } from '../../components/blocks/PageIntro';
import { Portfolio } from '../../components/blocks/Portfolio';
import { Posts } from '../../components/blocks/Posts';
import { Prose } from '../../components/blocks/Prose';
import { Container } from '../../components/Container';
import { WrittenOn } from '../../components/WrittenOn';
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();

  // `cms.routes` shows a post at `/blog/<slug>`, and checking `collection` narrows the record to a post.
  if (page.collection === 'Posts') {
    const post = page.record;
    const { blogPage } = await ohne.read('Settings', { select: ['blogPage'] });
    return (
      <main className="main main--bordered main--post" lang={page.locale}>
        <Container>
          <header className="post-header">
            <BackButton link={blogPage}>Blog</BackButton>
            <h1>{post.headline || post.title}</h1>
            <WrittenOn date={post.publishedAt} author={post.author} />
          </header>
        </Container>
        <OhneBlocks blocks={post.content} components={{ Image, Prose }} />
      </main>
    );
  }

  // 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, Posts }}
      />
    </main>
  );
}

The not-found page names the Posts block too:

apps/web/app/[[...slug]]/not-found.tsxlines 4-8
import { Image } from '../../components/blocks/Image';
import { PageIntro } from '../../components/blocks/PageIntro';
import { Portfolio } from '../../components/blocks/Portfolio';
import { Posts } from '../../components/blocks/Posts';
import { Prose } from '../../components/blocks/Prose';
apps/web/app/[[...slug]]/not-found.tsxlines 20-23
        <OhneBlocks
          blocks={page.content}
          components={{ PageIntro, Portfolio, Image, Prose, Posts }}
        />

Styles

The list, the buttons, the post header and the post's headings get their styles:

apps/web/app/styles/blocks.css
/* Page intro */

.page-intro {
  max-width: var(--content);
}

.page-intro__kicker {
  margin-top: 0.5rem;
  font-size: 0.875rem;
  line-height: 1rem;
}

/* Portfolio */

.portfolio__title {
  position: relative;
}

.portfolio__title::before {
  content: '';
  position: absolute;
  top: 50%;
  right: 100%;
  width: 0.5rem;
  height: 0.5rem;
  margin-right: 0.75rem;
  border-radius: 9999px;
  background: var(--heading);
  translate: 0 -50%;
  transition: background-color var(--slow) var(--ease);
}

.portfolio__grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 1.25rem;
  margin-top: 1.5rem;
}

.portfolio__item {
  position: relative;
  display: block;
  overflow: hidden;
  border-radius: var(--radius);
}

.portfolio__item img {
  width: 100%;
}

.portfolio__overlay {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  gap: 1rem;
  padding: 2rem;
  color: #fff;
  background: var(--overlay);
  opacity: 0;
  transition: opacity var(--slow) var(--ease);
}

.portfolio__caption {
  font-family: var(--font-heading);
  font-weight: 500;
  text-align: left;
  translate: 0 -0.75rem;
  transition: translate var(--slow) var(--ease);
}

.portfolio__overlay svg {
  width: 1.5rem;
  height: 1.5rem;
  margin: auto 0 0 auto;
  translate: 0 0.75rem;
  transition: translate var(--slow) var(--ease);
}

.portfolio__item:is(:hover, :focus-visible) .portfolio__overlay {
  opacity: 1;
}

.portfolio__item:is(:hover, :focus-visible) :is(.portfolio__caption, svg) {
  translate: 0 0;
}

@media (max-width: 1023px) {
  .portfolio__grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

@media (max-width: 767px) {
  .portfolio__title::before {
    width: 0.25rem;
    height: 0.25rem;
    margin-right: 0.625rem;
  }

  .portfolio__grid {
    grid-template-columns: minmax(0, 1fr);
    margin-top: 1.25rem;
  }

  .portfolio__overlay {
    padding: 1.5rem;
  }
}

/* Image and Prose share a narrower measure, and sit closer together. */

.content-block > * {
  max-width: var(--content);
}

.image-block {
  width: 100%;
  border-radius: var(--radius);
}

/* Posts */

.posts > * + * {
  margin-top: var(--section);
}

.posts__item {
  display: flex;
  align-items: center;
  gap: 2rem;
}

/* The list crops each post's image to a 1200 by 630 share card, as the design does. */
.posts__image {
  flex-shrink: 0;
  width: 23.75rem;
  aspect-ratio: 1200 / 630;
  object-fit: cover;
  border-radius: var(--radius);
}

.posts__body {
  flex: 1;
}

.posts__body .written-on {
  margin-top: 0.5rem;
  font-size: 0.8125rem;
}

.posts__description {
  margin-top: 1rem;
}

.posts__body .button {
  margin-top: 1.5rem;
}

.posts__pager {
  display: flex;
  gap: 2rem;
}

.posts__next {
  margin-left: auto;
}

@media (max-width: 1023px) {
  .posts__item {
    flex-direction: column;
  }

  .posts__image {
    width: 100%;
  }
}

Open http://localhost:3000/blog and follow a post:

A post titled Harmony in Space with a back link to the blog, its date and author Khadgar, and its photo

In the dashboard, open a post under Posts. The post page shows beside the form, as the pages do.

The dashboard editing a post, with the post page beside the form

Next, Contact form adds a form that stores each message in the CMS.

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-05-blog https://github.com/ohnejs/rayleigh-next rayleigh
Browse on GitHub