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.
Example
Section titled “Example”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.
PageTab
Section titled “PageTab”| 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 |
Reading values
Section titled “Reading values”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:
---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.
Handled for you
Section titled “Handled for you”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.
Limits
Section titled “Limits”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.
Ordering
Section titled “Ordering”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.