Skip to main content

Multi-page Pro Website — Acceptance Spec

Status: APPROVED 2026-05-21 — founder-confirmed. This spec is the source of truth: code that doesn't match it is wrong, and tests assert it.

Scope: Let a Pro Website customer add a small number (≤ 8) of separate pages — About, Ministries, Sermons, Contact, or a blank custom page — to what is today a single anchor-scroll page. A real multi-page site, not a CMS. Covers the page model, the public render at /s/[slug]/[page], the "Pages" group in the Website editor, and how pages ride the existing draft/publish flow.

Architecture source: knowledge/drafts/2026-05-21-pro-website-multipage-scoping.md (full options analysis + 14-point regression-risk table).


Foundational decisions

  1. Standard, not an add-on (founder decision 2026-05-21). Multi-page is included in every Pro Website plan (site-only $14.95, bundled $19.95) — no entitlement flag, no separate Stripe price, no tier gating. Available to every Pro Website subscriber. No pricing.yaml / PRICING.md / Stripe change.
  2. Page cap is a flat 8 (founder decision 2026-05-21). Every Pro Website plan gets up to 8 extra pages. The home page is not counted.
  3. Additive data model — the one-page site never regresses. Two new nullable jsonb columns (extra_pages, draft_extra_pages), default null. A church that never adds a page renders byte-identically to today. No data migration, no enum change, reversible.
  4. Constrained page model, not free-form HTML. Each extra page is an ordered list of a small fixed set of block types. No arbitrary widgets, no custom CSS, no scripts, no inline rich-text markup. This keeps every page on-brand and off the XSS surface.
  5. One site-wide draft/publish. Extra pages ride the existing premium_churches draft cycle. One Publish button publishes the home page and all extra pages together. No per-page publishing (that is the CMS complexity deliberately scoped out).

Tier visibility

Tier"Pages" editor group + multi-page render?
Pro Website site-only ($14.95)YES
Pro Website bundled with Chat ($19.95)YES
Chat / Voice / Suite / Bundle tiers (no Pro Website)N/A — no Website editor; /s/[slug]/[page] 404s
ITW / SermonWise / PewSearch PremiumN/A — different products

The page model

A single canonical column extra_pages jsonb holds ExtraPage[]. Its draft twin is draft_extra_pages.

ExtraPage = {
id: string // stable uuid, never reused
slug: string // url segment, lowercase a-z0-9-, 2-40 chars, unique within the site
title: string // <title> + on-page header, ≤ 80 chars
nav_label: string // nav link text, ≤ 24 chars
sort_order: number // nav + editor ordering
show_in_nav: boolean // when false, page renders + is linkable but is not in the nav
blocks: Block[] // ≤ 12 blocks
}

Block is a discriminated union of exactly 8 types:

Block typeFieldsRenders
headingtext (≤ 120 chars), level (2 | 3, default 2)A section heading in the page's denomination style
texttext (≤ 2,000 chars, plain text — line breaks preserved, no HTML)A paragraph block; blank lines split paragraphs
imageurl, alt (≤ 160), caption (≤ 200, optional)A centered, rounded image with optional caption
buttonlabel (≤ 40), hrefA branded call-to-action button (http/https/tel/mailto/anchor only)
staff_gridThe church's existing custom_staff, same cards as the home Staff section
ministries_gridThe church's existing custom_ministries, same cards as the home Ministries section
sermons_listThe church's existing sermons, same layout as the home Sermons section
contact_blockThe same ContactForm the home page uses (church-scoped)

Hard limits, enforced server-side in normalizeExtraPages() and client-side in the editor: ≤ 8 pages, ≤ 12 blocks per page, the per-field char caps above. Anything over the cap is rejected on save with a clear message; anything malformed is dropped defensively on render (never throws into the render path).


Routing & URLs

  • Home page is unchanged: /s/[slug] (subdomain slug.john316.church/).
  • Extra page: /s/[slug]/[page] (subdomain slug.john316.church/<page>). New route file; the home route file is untouched.
  • The middleware subdomain rewrite (*.john316.church/<path>/s/<slug>/<path>) already preserves the path — no middleware change.
  • revalidate = 3600 (ISR), same as the home route.
  • Reserved slugs the editor blocks and the route never serves as an extra page: privacy, terms, admin, api, _next, sitemap, robots, favicon, and the home-page section anchors (about, times, hours, office-hours, expect, beliefs, team, ministries, events, map, contact-form, giving). The literal privacy/terms route folders win by Next.js routing priority regardless.
  • A /s/[slug]/[page] request for a slug not present in the site's published extra_pages returns 404 (notFound()).
  • Links are root-relative (/about, /), matching the established /privacy + /terms + footer convention and the subdomain serving model.

Expected output — public visitor

A church with no extra pages (the default — 100% of existing customers)

  • /s/[slug] renders byte-identically to before this feature shipped. Nav, footer, sections — all unchanged.
  • /s/[slug]/anything returns 404.

A church with published extra pages

  • The home nav (StickyNav) shows the extra pages first, then the existing in-page section anchors. Overflow beyond 5 links collapses into the existing "More" dropdown.
  • Each extra page at /s/[slug]/[page]:
    • Reuses the same denomination styling, StickyNav, and TemplateFooter as the home page — a visual sibling of the home page.
    • Shows a modest header strip (the page title), lighter than the home video hero.
    • Renders its blocks top-to-bottom through BlockRenderer.
    • StickyNav is always visible on extra pages (a short page may never scroll past the home page's 200px reveal threshold). Its logo links to / (home). Its links are Home + the other extra pages.
    • The footer shows the extra-page links alongside Privacy / Terms / "Powered by ChurchWiseAI".
  • A page with show_in_nav = false still renders and is still reachable by direct link; it just is not listed in the nav.
  • generateMetadata per page: title = the page title, index: true (extra pages are indexable, unlike /privacy + /terms).

Expected output — pastor in the editor

In the Website tab editor (WebsiteTabEditor.tsx), a new "Pages" sidebar group sits below "Sections" / "Design":

  • Lists Home (pinned, not deletable, not reorderable) then each extra page, each with a drag handle and a settings control.
  • An "Add page" action (the previously-disabled "Add Section" placeholder button is now enabled) opens a small modal: 4 starter presets — About, Ministries, Sermons, Contact — plus Blank. Each preset pre-seeds sensible blocks so a non-technical pastor never faces an empty canvas.
  • Choosing a preset/Blank creates the page; its slug auto-derives from the title with the existing debounced uniqueness check, and reserved slugs are rejected inline.
  • Editing a page opens the block editor (in the existing SectionEditorSheet): a vertical list of block cards + an "Add block" picker offering the 8 block types. Each block type has a small form (heading text, paragraph text, image upload, button label/href). The 4 projection blocks (staff_grid, ministries_grid, sermons_list, contact_block) have no fields — they show a one-line "pulls from your Staff / Ministries / Sermons / Contact form" note.
  • Pages and blocks reorder by drag (up/down arrows on mobile). Rename warns that old links will break. Delete asks for confirmation.
  • All page edits flow through DraftAwareSaveForm/api/premium/update (website section, target=website_draft). They land on draft_extra_pages, the draft pill shows "unpublished changes", and the preview iframe reloads — exactly like every other Website edit.
  • Clicking Publish copies draft_extra_pages → extra_pages (and every other draft column) atomically. Extra-page ISR paths are revalidated so the new/changed pages go live immediately.

Draft / publish contract

  • draft_extra_pages → extra_pages is registered in DRAFT_TO_CANONICAL, DRAFT_SELECT_COLUMNS, and ProWebsiteDraftColumns (src/lib/pro-website-draft.ts). The publish route iterates DRAFT_TO_CANONICAL, so publishing extra pages needs no publish-route logic change beyond revalidation.
  • /api/premium/update: the website section gains an additive extra_pages field, parsed/validated through normalizeExtraPages() behind its own formData.has('extra_pages') guard. The existing events-parse block (the one carrying the name-vs-title fix) is not touched.
  • The ?draft=<admin_token> preview overlays draft_extra_pages onto extra_pages for both the home route (nav) and the [page] route (content), keyed on the admin-token match — same mechanism as every other draft column.
  • Publish revalidates /s/[slug] and every /s/[slug]/<page-slug> path.

Regression guardrails (must hold)

  1. A church with extra_pages = null renders /s/[slug] byte-identically — verified by a no-regression snapshot test.
  2. The home route file (s/[slug]/page.tsx) only gains the additive nav extension + the additive extra_pages draft-overlay line — both no-ops when extra_pages is null.
  3. UnifiedTemplate, StickyNav, TemplateFooter each gain only optional props that default to the pre-feature behavior.
  4. service_business / funeral demo pages never render SimplePageTemplate — the [page] route is gated to non-service_business, Pro-Website-plan churches, with the same orphan-FK notFound() guard as the home route.
  5. Critical-path Playwright suites cwa-pro-website-ssr and cwa-pro-website-edit run green; new specs are added (single-page no-regression, multi-page render, page save→publish) rather than altering existing assertions. A pro-website-multipage registry entry is added.

Out of scope (v1)

  • Per-page publishing, per-page analytics, page version history.
  • Inline rich text (bold/italic/links inside a paragraph) — text blocks are plain text only.
  • Nested layouts, multi-column blocks, custom CSS, embedded scripts.
  • Planning Center calendar content as a page — when PC integration composes here, it arrives as a future block type in this same union, not a parallel page system (the two scopes agreed: page model stays additive).