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

to navigateto open

Lesson 06

Contact form

Add a contact form to Rayleigh: a collection anyone may write to, a Next Server Action that hands it each message, and an optional reCAPTCHA check.

Start from the previous lesson's code

The Contact page with fields for a name, an email address and a message, and a Send button

Messages

Each message a visitor sends becomes a record of a new collection. Its create is open to anyone, so a visitor needs no account, and only an editor reads what arrives. See Exposure.

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

// The messages visitors send with the contact form on your website.
export default defineCollection({
  fields: {
    // A message stays as its visitor wrote it: the API offers no update, and `immutable` refuses one too.
    name: field('text', { max: 100, immutable: true }),
    // An `email` field refuses anything that is not an email address.
    email: field('email', { immutable: true }),
    message: field('text', { multiline: true, max: 5000, immutable: true }),
  },
  api: {
    // Anyone may send a message, and nobody but an editor may read one.
    // `middleware/recaptcha.ts` checks each message for spam before it is stored.
    // See https://ohne.dev/docs/api/collections#exposure
    create: { public: true, middleware: ['recaptcha'] },
    read: true,
    delete: true,
  },
  dashboard: { icon: 'mail', recordLabel: 'name' },
});

The block

The form has nothing an editor changes, so its block has no fields:

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

// A block without fields: your website renders the form, and it sends each message to `Messages`.
export default defineBlock({
  description: 'A form that sends a message to the Messages collection',
  fields: {},
  dashboard: { icon: 'forms' },
});

Let Pages hold it:

apps/backend/collections/Pages.tslines 16-18
    content: field('blocks', {
      allow: ['PageIntro', 'Portfolio', 'Image', 'Prose', 'Posts', 'ContactForm'],
    }),

The spam check

reCAPTCHA v3 scores each visitor without a puzzle. It takes two keys: a site key, which loads Google's script in every visitor's browser, and a secret key, which the backend sends to Google with each token. An editor turns the check on in Settings, and the keys show only while it is on. See Conditional fields.

apps/backend/collections/Settings.tslines 1-18
import { defineCollection, field } from 'ohnejs';
import { requireUser, userCan } from 'ohnejs/auth';

// The site-wide settings your website reads: the logo, the menu, the footer links and the spam check.
// A singleton holds exactly one record, so the dashboard opens it straight away.
// See https://ohne.dev/docs/cms/website#site-data
export default defineCollection({
  singleton: true,
  api: {
    // Anyone may read the settings, and only an editor may change them.
    read: 'public',
    update: {
      // Only an admin may change the secret key: the `admin` role's `*` covers `settings.secrets`.
      // See https://ohne.dev/docs/api/collections#the-write-input
      access: async ({ input }) =>
        !('recaptchaSecretKey' in input) || userCan(await requireUser(), 'settings.secrets'),
    },
  },
apps/backend/collections/Settings.tslines 57-81
    recaptcha: field('boolean', {
      default: false,
      display: 'switch',
      label: 'Check messages with reCAPTCHA',
      description:
        'Google reCAPTCHA v3 scores each visitor, and a message from a likely bot is refused.',
    }),
    // `when` shows the keys only while the check is on.
    // See https://ohne.dev/docs/database/conditional-fields
    recaptchaSiteKey: field('text', {
      nullable: true,
      when: { recaptcha: true },
      label: 'Site key',
      description: 'From the reCAPTCHA admin console. Your website shows it to every visitor.',
    }),
    // `readable: false` keeps the secret out of every read, even the admin's, as a password is kept.
    // Only the backend's own code reads it, to ask Google about a message.
    // See https://ohne.dev/docs/database/collections#write-only-and-locked-fields
    recaptchaSecretKey: field('text', {
      nullable: true,
      readable: false,
      when: { recaptcha: true },
      label: 'Secret key',
      description: 'Saved, and never shown again. Type a new key to replace it.',
    }),
apps/backend/collections/Settings.tslines 91-94
          {
            label: 'Contact form',
            fields: ['recaptcha', { row: ['recaptchaSiteKey', 'recaptchaSecretKey'] }],
          },

Your CMS already holds its Settings record, and the sync refuses a new required field over it. A migration fills the switch in, turned off. See Filling.

apps/backend/migrations/2026-10-25-settings-recaptcha.ts
import { defineMigration } from 'ohnejs';

// A site made before the contact form already holds its Settings row, and the new switch starts off there.
export default defineMigration({
  from: null,
  to: { collection: 'Settings', field: 'recaptcha' },
  transform: () => false,
});

Whoever holds the secret key can ask Google about tokens in your site's name, so it never leaves the backend: no read returns it, and only the admin may replace it. See Write-only and locked fields and Capabilities.

A route middleware does the check. Messages names it on its create, so it runs before each message is stored:

apps/backend/middleware/recaptcha.ts
import { defineMiddleware, forbidden, query } from 'ohnejs';

/**
 * What Google answers about a reCAPTCHA token.
 * See https://developers.google.com/recaptcha/docs/v3#site_verify_response
 */
interface Verdict {
  success: boolean;
  score?: number;
}

// A middleware runs before the request's own work, and a throw answers the request instead.
// `Messages` names this one on its create, so a message is checked before it is stored.
// See https://ohne.dev/docs/api/middleware#route-middleware
export default defineMiddleware(async (event) => {
  // Server code is trusted, so it may select the write-only secret key.
  const settings = await query('Settings').select('recaptcha', 'recaptchaSecretKey').findFirst();
  if (!settings.recaptcha) return;

  // The website asks reCAPTCHA for a token when the visitor sends the form, and passes it on in this header.
  const token = event.request.headers.get('recaptcha-token');
  if (!(await isPerson(token, settings.recaptchaSecretKey))) {
    throw forbidden('The spam check failed. Please try again.');
  }
});

/**
 * Asks Google whether the visitor who got `token` is likely a person.
 */
async function isPerson(token: string | null, secret: string | null): Promise<boolean> {
  if (!token || !secret) return false;
  const response = await fetch('https://www.google.com/recaptcha/api/siteverify', {
    method: 'POST',
    body: new URLSearchParams({ secret, response: token }),
  });
  const verdict = (await response.json()) as Verdict;
  // reCAPTCHA v3 scores a visitor from 0 (a bot) to 1 (a person), and Google suggests 0.5 as the line.
  return verdict.success && (verdict.score ?? 0) >= 0.5;
}

The content

The seed adds the Contact page with the block, its menu item, and a site description that names the form:

apps/backend/boot/seed.tslines 93-104
  const contact = await query('Pages').createOrThrow({
    title: 'Contact',
    slug: 'contact',
    status: 'published',
    footerBorder: true,
    seo: { description: 'Contact us and get in touch.' },
    content: [
      { block: 'PageIntro', fields: { title: 'Contact', kicker: 'Get in touch.' } },
      // The website renders the form, and each message lands in `Messages`.
      { block: 'ContactForm', fields: {} },
    ],
  });
apps/backend/boot/seed.tslines 126-132
    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 } },
      { label: 'Contact', link: { collection: 'Pages', record: contact.UUID } },
    ],
apps/backend/boot/seed.tslines 149-153
  await query('Site').update({
    name: 'Rayleigh',
    description:
      'Rayleigh is a minimalistic portfolio template made with ohne, with a gallery, an About page, a blog and a contact form.',
  });

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 with the new password the terminal prints.

Sending a message

The browser never calls the API. The form calls a Server Action on your website's server, which hands the message to Messages with ohne.create. See Forms.

apps/web/lib/contact.ts
'use server';

import type { CreateResult } from '@ohnejs/client';

import type { Creatable } from '../ohne';

import { ohne } from './ohne';

// A Server Action runs on your website's server, though the browser calls it like a function.
// It hands the message to your CMS, which checks every field, and returns the CMS's answer to the form.
// See https://ohne.dev/docs/cms/website#forms
export async function sendMessage(
  { name, email, message }: Creatable['Messages'],
  recaptcha?: string,
): Promise<CreateResult> {
  return ohne.create(
    'Messages',
    { name, email, message },
    // The backend's `recaptcha` middleware reads the token from this header.
    { headers: recaptcha ? { 'recaptcha-token': recaptcha } : {} },
  );
}

When the CMS refuses a message, create resolves with ok: false, a message, and the CMS's message for each invalid field under errors. The action returns that answer to the form. See Write failures.

The form

A text input is a label, its field, and the CMS's message under it:

apps/web/components/TextInput.tsx
import { useId } from 'react';

/**
 * Props for `TextInput`.
 */
export interface TextInputProps {
  /**
   * The text above the field.
   */
  label: string;

  /**
   * The field's name in the form's data.
   */
  name: string;

  /**
   * A one-line text or email field, or a text area.
   *
   * @default
   * 'text'
   */
  type?: 'text' | 'email' | 'textarea';

  /**
   * What the browser may fill in, like `name` or `email`.
   */
  autoComplete?: string;

  /**
   * The CMS's message for an invalid value.
   */
  error?: string;
}

export function TextInput({ label, name, type = 'text', autoComplete, error }: TextInputProps) {
  // `useId` gives the same id on the server and in the browser, so the label finds its input after hydration.
  const id = useId();
  const field = {
    id,
    name,
    className:
      type === 'textarea' ? 'text-input__field text-input__field--area' : 'text-input__field',
    'aria-invalid': Boolean(error),
    'aria-describedby': error ? `${id}-error` : undefined,
  };
  return (
    <div className="text-input">
      <label htmlFor={id} className="text-input__label">
        {label}
      </label>
      {type === 'textarea' ? (
        <textarea rows={12} {...field} />
      ) : (
        <input type={type} autoComplete={autoComplete} spellCheck={false} {...field} />
      )}
      {error && (
        <p id={`${id}-error`} className="text-input__error">
          {/* ohne wraps the values in its messages in backticks, and the form shows them as code. */}
          {error
            .split('`')
            .map((part, index) => (index % 2 ? <code key={index}>{part}</code> : part))}
        </p>
      )}
    </div>
  );
}

Without to, the button is a real <button>, the form's Send:

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

import Link from 'next/link';

/**
 * Props for `Button`.
 */
export interface ButtonProps {
  /**
   * Where the button leads. Without it, the button is a real `<button>`, like the contact form's Send.
   */
  to?: string;

  /**
   * The `<button>`'s type, when the button has no `to`.
   *
   * @default
   * 'button'
   */
  type?: 'button' | 'submit';

  /**
   * More classes, beside `button`.
   */
  className?: string;

  children: ReactNode;
}

export function Button({ to, type = 'button', className, children }: ButtonProps) {
  const classes = className ? `button ${className}` : 'button';
  const label = <span className="button__label">{children}</span>;
  if (!to)
    return (
      <button type={type} className={classes}>
        {label}
      </button>
    );
  return (
    <Link href={to} className={classes}>
      {label}
    </Link>
  );
}

The reCAPTCHA helpers name Google's script and ask it for a token:

apps/web/lib/recaptcha.ts
/**
 * The part of Google's reCAPTCHA v3 script the contact form uses.
 * See https://developers.google.com/recaptcha/docs/v3
 */
interface ReCaptcha {
  ready(callback: () => void): void;
  execute(siteKey: string, options: { action: string }): Promise<string>;
}

declare global {
  interface Window {
    grecaptcha?: ReCaptcha;
  }
}

/**
 * The address of reCAPTCHA's script for `siteKey`.
 * The script watches how the visitor uses the page, so it loads with the page, not on submit.
 */
export function recaptchaScript(siteKey: string): string {
  return `https://www.google.com/recaptcha/api.js?render=${encodeURIComponent(siteKey)}`;
}

/**
 * Asks reCAPTCHA for a fresh token, which the backend hands to Google to score the visitor.
 * A token expires after two minutes, so the form asks for one on each submit.
 */
export async function recaptchaToken(siteKey: string): Promise<string> {
  const { grecaptcha } = window;
  if (!grecaptcha) throw new Error('reCAPTCHA did not load');
  await new Promise<void>((resolve) => grecaptcha.ready(resolve));
  return grecaptcha.execute(siteKey, { action: 'contact' });
}

The form keeps state and runs in the browser, so it is a client component. It calls the action and shows what the CMS answers:

apps/web/components/MessageForm.tsx
'use client';

import type { Refused } from '@ohnejs/client';

import Script from 'next/script';
import { type FormEvent, useEffect, useRef, useState } from 'react';

import { sendMessage } from '../lib/contact';
import { recaptchaScript, recaptchaToken } from '../lib/recaptcha';
import { Button } from './Button';
import { TextInput } from './TextInput';

/**
 * Props for `MessageForm`.
 */
export interface MessageFormProps {
  /**
   * reCAPTCHA's site key while the spam check is on in `Settings`, or `null` while it is off.
   */
  siteKey: string | null;
}

// The form runs in the browser, and `sendMessage` hands each message to the CMS on the server.
export function MessageForm({ siteKey }: MessageFormProps) {
  const form = useRef<HTMLFormElement>(null);
  const thanks = useRef<HTMLParagraphElement>(null);
  const [sending, setSending] = useState(false);
  const [sent, setSent] = useState(false);
  const [refusal, setRefusal] = useState<Partial<Refused>>();
  const errors = refusal?.errors ?? {};

  useEffect(() => {
    if (sent) thanks.current?.focus();
    // Focus moves to the first field to fix, so a keyboard or screen reader visitor lands on it.
    else form.current?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus();
  }, [sent, refusal]);

  async function send(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const data = new FormData(event.currentTarget);
    const text = (name: string) => String(data.get(name) ?? '');
    setSending(true);
    try {
      const recaptcha = siteKey ? await recaptchaToken(siteKey) : undefined;
      const message = { name: text('name'), email: text('email'), message: text('message') };
      const result = await sendMessage(message, recaptcha);
      if (result.ok) setSent(true);
      else setRefusal(result);
    } catch {
      setRefusal({});
    }
    setSending(false);
  }

  if (sent)
    return (
      <p ref={thanks} className="contact-form__note" tabIndex={-1}>
        Thank you for your message! We'll get back to you as soon as possible.
      </p>
    );

  const failure =
    refusal && Object.keys(errors).length === 0
      ? refusal.message || 'Something went wrong. Please try again.'
      : undefined;
  return (
    // `noValidate` leaves every check to the CMS, so the form shows the CMS's own messages.
    <form ref={form} className="contact-form" noValidate onSubmit={send}>
      {/* The spam check is a switch in `Settings`, so reCAPTCHA's script loads only while it is on. */}
      {siteKey && <Script src={recaptchaScript(siteKey)} />}
      {/* While the message is sent, a disabled `<fieldset>` disables every field and the button. */}
      <fieldset className="contact-form__fields" disabled={sending}>
        {failure && (
          <p className="contact-form__note contact-form__note--error" role="alert">
            {failure}
          </p>
        )}
        <TextInput label="Your name" name="name" autoComplete="name" error={errors.name} />
        <TextInput
          label="Your email address"
          name="email"
          type="email"
          autoComplete="email"
          error={errors.email}
        />
        <TextInput label="Message" name="message" type="textarea" error={errors.message} />
        <Button type="submit">Send</Button>
      </fieldset>
    </form>
  );
}

The block itself renders on the server. It reads the spam check from Settings and hands the form its site key:

apps/web/components/blocks/ContactForm.tsx
import { ohne } from '../../lib/ohne';
import { Container } from '../Container';
import { MessageForm } from '../MessageForm';

// The block has no fields to edit: it renders the form, and each message lands in `Messages`.
// It renders on the server, so it reads the spam check from `Settings` and hands the form its key.
export async function ContactForm() {
  const { recaptcha, recaptchaSiteKey } = await ohne.read('Settings', {
    select: ['recaptcha', 'recaptchaSiteKey'],
  });
  return (
    <Container className="content-block">
      <MessageForm siteKey={recaptcha ? recaptchaSiteKey : null} />
    </Container>
  );
}

Name it in the page:

apps/web/app/[[...slug]]/page.tsxlines 3-5
import { BackButton } from '../../components/BackButton';
import { ContactForm } from '../../components/blocks/ContactForm';
import { Image } from '../../components/blocks/Image';
apps/web/app/[[...slug]]/page.tsxlines 44-47
      <OhneBlocks
        blocks={page.record.content}
        components={{ PageIntro, Portfolio, Image, Prose, Posts, ContactForm }}
      />

And in the not-found page:

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

Styles

The fields, the notes and a danger color get their styles:

apps/web/app/styles/tokens.css
/* Every color, font and size the other stylesheets use. The dark theme swaps the colors only. */
/* Breakpoints, widest first: 1199px (small laptop), 1023px (tablet), 767px (phone). */
:root {
  --scheme: light;
  --heading: #010203;
  --copy: #234345;
  --accent: #268baa;
  --border: #e2e4e8;
  --dim: #59797b;
  --danger: #dc2626;
  --page: #fff;
  --ink: #010203;
  --overlay: rgb(1 2 3 / 0.7);
  --backdrop: rgb(1 2 3 / 0.8);
  --menu-backdrop: rgb(255 255 255 / 0.75);
  --badge: #234345;
  --badge-icon: #fff;

  --font-copy: Lato, ui-sans-serif, system-ui, sans-serif;
  --font-heading: Poppins, ui-sans-serif, system-ui, sans-serif;

  --container: 73.75rem;
  --content: 48.75rem;
  --gutter: 3rem;
  --section: 5.75rem;
  --radius: 0.375rem;
  --ease: cubic-bezier(0.4, 0, 0.2, 1);
  --fast: 150ms;
  --slow: 300ms;
}

/* A visitor who asks for less motion gets every change at once, a theme switch included. */
@media (prefers-reduced-motion: reduce) {
  :root {
    --fast: 0ms;
    --slow: 0ms;
  }
}

/* The theme script in the page's `<head>` sets `data-theme` before the first paint. */
:root[data-theme='dark'] {
  --scheme: dark;
  --heading: #fff;
  --copy: rgb(255 255 255 / 0.8);
  --border: rgb(255 255 255 / 0.1);
  --danger: #f87171;
  --page: #010203;
  --menu-backdrop: rgb(1 2 3 / 0.8);
  --badge: #fff;
  --badge-icon: #010203;
}

@media (max-width: 1199px) {
  :root {
    --gutter: 2rem;
  }
}

@media (max-width: 767px) {
  :root {
    --gutter: 1.5rem;
  }
}

Try it

Open http://localhost:3000/contact and send the form empty. Each field shows the CMS's own message:

The contact form with a red message from the CMS under each of its fields

Send a real message, and it arrives under Messages in the dashboard:

The Messages collection in the dashboard, listing a message from Jaina Proudmoore

To turn the spam check on, create a reCAPTCHA v3 key pair in the reCAPTCHA admin console, with localhost among its domains. Then turn on the switch on the Contact form tab of Settings, and enter both keys.

That is all of Rayleigh. Read SEO and redirects and Deployment to put a site like it online.

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