Skip to content

Page tabs

A page tab puts your fields on every page’s editor, beside Sections, Settings, SEO and Open Graph. The values are stored on the page row and travel with it through validation, revisions, history and restore, because they join the page’s own form rather than getting a save path of their own.

src/plugins/redirects/index.ts
import { z } from 'zod';
import type { Plugin } from '@ouncepage/core';
const schema = z.object({
from: z.array(z.string().trim()).default([]),
canonical: z.string().trim().default(''),
});
export function redirects(): Plugin {
return {
name: 'redirects',
title: 'Redirects',
blurb: 'Old paths that should land on this page, and its canonical URL.',
pageTabs: [
{
id: 'routing',
label: 'Routing',
fields: [
{
name: 'from',
label: 'Old paths',
type: 'stringlist',
help: 'One path per line, each starting with a slash.',
},
{
name: 'canonical',
label: 'Canonical URL',
type: 'url',
help: 'Leave empty to use this page’s own address.',
},
],
schema,
defaults: { from: [], canonical: '' },
},
],
};
}

That is the whole integration. No migration, no save handler, no route.

Key Type Meaning
id string Namespaced to <plugin>:<id>. Also the storage key. No dots
label string The tab label
fields Field[] Any field types, including your own
schema ZodType Validates the tab’s object on save
defaults Record<string, unknown> Used when the page has no value yet

They are stored in pages.extras, a JSON column, keyed by the namespaced id: <plugin>:<tab>.

{ "redirects:routing": { "from": ["/old"], "canonical": "https://example.com/x" } }

page.extras is parsed JSON and nothing more. Your schema runs on save, not on read, so a page that has not been saved since you added the plugin has no key at all. Narrow it yourself:

src/templates/Page.astro
---
import { z } from 'zod';
const routingSchema = z.object({
from: z.array(z.string()).default([]),
canonical: z.string().default(''),
});
const { view } = Astro.props;
const routing = routingSchema.catch({ from: [], canonical: '' })
.parse(view.page.extras['redirects:routing'] ?? {});
const canonical = routing.canonical || `${Astro.site}${view.page.slug}`;
---
<link rel="canonical" href={canonical} />

.catch() rather than .parse() alone: an old row written by an earlier version of your schema should degrade to defaults, not throw on a public page.

Because the tab’s fields are part of the page form rather than a separate write:

  • Validation runs with the rest of the page, and errors land on your tab.
  • Rich text is sanitised on the same pass as the site’s own.
  • Every change is recorded as a revision on the page.
  • The per-field history clock works, and a value can be restored on its own.
  • Read-only mode disables your fields with everything else.

Everything a page tab contributes has to be expressible as fields plus a Zod schema. If your plugin needs a custom save path, a second table, or a control that does not fit the field model, a page tab is the wrong shape. Use an admin screen instead.

No dots in id or in a field name. Input names are dotted paths, and the form reader splits on every dot. A dot in an id makes the value rebuild into the wrong shape and save as empty, with no error.

Give every field a .default() in the schema, for the reason in the note above.

Do not assume the tab exists. A page saved before your plugin was installed has no key for it.

Plugin tabs always follow the four built-in tabs, in plugin order. There is no way to insert one between Settings and SEO, and no way to hide a built-in tab.