Spring til hovedindhold

Website Builder Architecture

Every church website served by B1App is rendered from a content tree — pages, sections, elements — stored in the ContentApi and edited visually in B1Admin. One shared component library renders both the editor preview and the live site, one element-type catalog defines what can appear on a page, and a separate AI service can generate or rewrite that tree. This page maps the whole stack: the element contract in @churchapps/helpers, the render pipeline, church-data elements, site-wide widgets, the blog layer, access-gated pages, SEO, AI generation, and conversational forms.

Overview​

┌──────────────────────────────┐             ┌─────────────────────────────────────────┐
│ B1Admin — editor │ │ Api — /content module (ContentApi) │
│ ContentEditor · SectionEdit │ POST /… │ │
│ ElementEdit · PageLinkEdit │ ──────────▶ │ pages ─ sections ─ elements blocks │
│ SiteWidgetsEdit · Blog │ │ posts redirects settings styles │
└──────────┬───────────────────┘ └───────────────┬─────────────────────────┘
│ │ GET /content/pages/:churchId/tree?url=…
│ shared render pipeline ▼ (anon, JWT honored)
│ ┌───────────────────────────────┐ ┌─────────────────────────────────┐
└──▶│ @churchapps/helpers │◀──│ B1App — public site (Next.js) │
│ ElementTypes.ts (catalog) │ │ Zone → Section → Element │
│ @churchapps/apphelper │ │ + widgets, JSON-LD, sitemap, │
│ ElementRegistry, renderers │ │ redirects, branded 404 │
│ SectionDivider, widgets │ └───────────────┬─────────────────┘
└───────────────────────────────┘ │ church-data elements
┌──────────────────────────────┐ ▼
│ AskApi — /website/* (AI) │ ┌─────────────────────────────────────────┐
│ generateSite · rewriteSection│ │ /giving/funds/public/…/total │
│ generateAltText · metaDesc │ │ /membership/groupmembers/public/… │
│ returns JSON; B1Admin saves │ │ /attendance/servicetimes/public/… │
└──────────────────────────────┘ └─────────────────────────────────────────┘

Three rules hold across the stack:

  1. One tree, two renderers. A page is a pages → sections → elements tree where every node carries its settings as an answers JSON blob. The same apphelper components render the drag-and-drop editor in B1Admin and the server-rendered public site in B1App — there is no separate "publish format".
  2. The contract lives in @churchapps/helpers. ElementTypes.ts is the single catalog of element types; renderers resolve through a registry in apphelper; editor forms live in B1Admin. Adding an element type means touching all three, in that order.
  3. The public site reads anonymous endpoints. Everything B1App needs — the page tree, settings, blog posts, redirects, and the church-data endpoints in other modules — is public. Auth is optional: a JWT on the anonymous tree endpoint unlocks members-only pages, nothing else changes.

The content tree​

The content module (Api/src/modules/content) owns the builder's data:

TableRole
pagesOne page per URL: url, title, layout, plus visibility/groupIds (access gating) and metaDescription (SEO)
sectionsHorizontal bands on a page (or in a block): background, text color, and an answersJSON that carries styling plus the dividerTop/dividerBottom shape-divider configs
elementsContent pieces inside a section: elementType + answersJSON, nestable for layout types (row/column, carousel)
blocksReusable section/element groups (footer blocks, element blocks) shared across pages
postsStandalone blog posts (see Blog)
redirectsPer-church fromPath → toPath pairs, capped at 200 (see SEO)
settingsKey-value church settings; rows flagged public are served anonymously and carry the widget/analytics config

The whole tree for one URL comes back from a single anonymous call — GET /content/pages/:churchId/tree?url=/about — which is what B1App server-renders from. Editor requests fetch by id instead and keep internal ids.

The element contract​

The catalog (@churchapps/helpers)​

Packages/helpers/src/ElementTypes.ts defines every element type as an ElementTypeDefinition: elementType, label, category, schemaVersion, defaults, and a JSON-schema-style answersSchema for its answers. validateElementAnswers() is deliberately lenient — unknown types and extra keys pass, so old content never breaks on a catalog upgrade. 35 types ship today:

CategoryElement types
layout (6)row, column, box, carousel, whiteSpace, block
content (11)text, textWithPhoto, card, faq, iconFeature, testimonial, socialIcons, countdown, stats, table, buttonLink
media (4)image, gallery, video, map
church (12)logo, sermons, stream, donation, donateLink, form, calendar, groupList, groups, campaignProgress, staffGrid, serviceTimes
advanced (2)rawHTML, iframe

The sermons element is the most configurable of the church types: a layout answer selects browse (the legacy full browser), grid, list, or featuredLatest, with playlistId, itemCount, showTitles, and showDates refining the non-browse layouts.

Renderers (@churchapps/apphelper)​

Renderers live in Packages/apphelper/src/website/components/elementTypes/, one component per type, resolved through ElementRegistry.ts — a two-layer map where Element.tsx registers the default renderer for all 35 types (registerDefaultElementRenderer) and a host app can override any of them at runtime (registerElementRenderer) without forking the package.

Editor forms (B1Admin)​

The editor's per-type settings forms live in B1Admin/src/site/admin/elements/ — ElementEdit.tsx dispatches to a dedicated component (GalleryEdit, TestimonialEdit, StatsEdit, …) or an inline field builder per type. The AI-facing mirror of this catalog is the API's MCP describe_page_builder tool (see MCP Server).

Section shape dividers​

Sections can carry decorative shape dividers on either edge. The config lives in the section's answersJSON as dividerTop / dividerBottom objects — { shape, color, height, flip } with shape one of wave, waves, slant, curve, triangle, peaks. Apphelper ships the SectionDivider component and parseDividerConfig() helper; both apps' Section renderers (B1App/src/components/Section.tsx, B1Admin/src/site/admin/Section.tsx) parse the answers and mount the divider, and SectionEdit.tsx in B1Admin provides the picker UI. The packages only ship the building block — the section-level wiring is the consuming apps' job.

Church-data elements​

Three element types render live church data rather than authored content. Module isolation still applies — each one calls the owning module's own public endpoint from the browser:

ElementEndpointNotes
campaignProgressGET /giving/funds/public/:churchId/:fundId/totalReturns { fundId, totalAmount, donationCount }, optional ?startDate=&endDate= window; the element compares it against its goalAmount answer
staffGridGET /membership/groupmembers/public/:churchId/:groupIdOpt-in only: the group must have publicRoster set (default off). The projection is deliberately minimal — personId, displayName, leader, photo — no contact or demographic fields
serviceTimesGET /attendance/servicetimes/public/:churchIdReturns the campus → service → time tree; the apphelper renderer emits best-effort schema.org Event JSON-LD from it (the API returns plain data)
advarsel

publicRoster is the privacy gate for staffGrid. Never widen the public group-member projection or bypass the flag — the roster endpoint is anonymous by design and the minimal field list is the safety property.

Site-wide widgets​

Two widgets render on every public page rather than inside the tree: AnnouncementBanner (dismissible top-of-page bar) and Launcher (floating action hub for give/visit/watch-style links). Both components and their parse*Config() helpers ship in apphelper. Configuration is two public settings rows — keys announcementBanner and launcher — written by B1Admin's SiteWidgetsEdit (on the Appearance page) and read by B1App's public layout via GET /content/settings/public/:churchId. The API treats these as opaque key-value pairs; the key names are a convention between the two apps.

Blog​

The blog is a standalone content type, not a layer over builder pages. A posts row holds the whole post: title, slug, excerpt, content (markdown body), authorId, photoUrl, publishDate, category, tags. Public surface (all anonymous, PostController):

RoutePurpose
GET /content/posts/public/:churchIdPublished posts, filterable by ?category=&tag=, paginated
GET /content/posts/public/:churchId/categoriesDistinct categories across published posts
GET /content/posts/public/:churchId/slug/:slugOne published post
GET /content/posts/rss/:churchId?siteUrl=RSS 2.0 feed, titled with the church name, with per-item category and excerpt-or-content description

A post is "published" once publishDate is set and past; a future publishDate is a scheduled post (hidden publicly, shown with a Scheduled chip in admin). Read endpoints enrich each post with authorName, resolved from authorId through the membership module gateway. Missing excerpts fall back to stripped-markdown content (~160 chars) in listing cards, meta descriptions, and RSS. B1App serves /{sdSlug}/blog — an editorial listing (centered header that becomes the active category/tag name when filtered, category-chip filter row, thumbnail-left post rows with bylines and excerpts) with the RSS feed advertised as an alternate link — and /{sdSlug}/blog/[postSlug], a dedicated route (not the Zone/Section pipeline) with a centered header (category kicker, title, byline, primary-color accent rule), a 16:9 hero at container width, the markdown body in a ~720px reading column, tag chips in the article footer, a "More in {category}" related-posts strip, and BlogPosting JSON-LD including the author. Both pages style entirely from theme tokens so they inherit each church's palette. Blog URLs are included in the per-church sitemap. B1Admin's authoring UI (Site → Blog) edits posts in a dialog: markdown editor with preview toggle, 16:9-cropped gallery image picker, author person-picker (defaults to the editing user), category autocomplete seeded from existing categories, duplicate-slug validation, and a publish toggle; published rows link out to the live post, and the page nudges admins to add a /blog navigation link.

Members-only pages​

pages.visibility reuses the navigation-links enum — everyone (default), visitors, members, staff, team, groups (with groupIds) — but as a hard access gate, not a nav filter (PageVisibilityHelper.canViewPage). The flow:

  1. The anonymous tree endpoint checks visibility on URL-based fetches. Anonymous callers of a gated page get { restricted: true, visibility } instead of content — the tree never leaks.
  2. The endpoint still honors a JWT: CustomAuthProvider verifies the Authorization header on every request, including anonymous routes, so an authenticated member's fetch of the same URL resolves normally.
  3. B1App renders RestrictedPage on a restricted response: it hydrates the session from stored credentials, re-fetches the tree with the JWT, and renders it — or shows a login gate with a returnUrl when there is no session.
info

The gate's granularity varies by level: groups checks the token's groupIds against the page's list and staff checks membershipStatus, but members and team currently pass any authenticated user of the church. Treat groups as the strict option.

SEO and discoverability​

All of this is B1App-side rendering over ContentApi data — the API stores, the app emits:

ConcernHow it works
Meta descriptionspages.metaDescription (≤300 chars) flows through MetaHelper.getMetaData() into the Next.js Metadata (description + Open Graph) on every builder-rendered route. B1Admin's page settings include an AI "Generate" button (see below)
RedirectsPer-church redirects rows managed at /content/redirects (content.edit, 200-row cap, normalized paths). On a would-be 404, B1App's page route resolves the path against GET /content/redirects/public/:churchId and issues an HTTP 308 via Next's permanentRedirect; unmatched paths fall through to notFound()
Branded 404not-found.tsx renders BrandedNotFound with the church's logo, name, and theme instead of a generic error
Structured dataBlogPosting JSON-LD on blog posts; VideoObject on the per-sermon pages (/{sdSlug}/sermons/[sermonId]) and on pages containing a sermons element; Event from calendar/event elements on builder pages; schema.org Event from the serviceTimes element
Sermon pagesEvery public sermon gets a crawlable page at /sermons/[sermonId] with full metadata — sermons are no longer locked inside the client-side browser element
AnalyticsThe public settings key ga4MeasurementId (managed next to redirects in B1Admin) injects a per-church GA4 gtag via next/script
Sitemap & feedsThe per-church sitemap.xml route includes builder pages and blog URLs; the blog listing advertises the RSS feed
AccessibilityThe public chrome renders a skip link targeting the <main id="main-content"> landmark in every layout wrapper

AI generation (AskApi)​

Page and site generation runs in AskApi, a separate service, under the /website controller. It authenticates with the same CustomAuthProvider JWT as everything else and is stateless with respect to content: every endpoint returns JSON and the caller (B1Admin) persists the result through ContentApi (POST /content/pages/importTree creates a page with its full nested section/element tree in one call; it always inserts under the caller's church and ignores ids in the body).

Page generation (planPage → writePage)​

The "AI" page template in B1Admin's AddPageModal uses a low-cost pipeline (AskApi/src/helpers/SiteGenHelper.ts) built on one rule: no model ever emits builder JSON. Two models split the work through the Vercel AI Gateway (plain HTTP, SSM key /{env}/aiGatewayApiKey or AI_GATEWAY_API_KEY):

  • JEV (typesafe-ai/jev) — a typed-decision model that returns choices, scores and booleans with probabilities but cannot write text. It picks each section in turn from a fixed template library, scores layouts, fact-checks copy, and picks stock photos and icons. Input costs about $0.04 per million tokens and output is free, so ~90 calls per page cost a fraction of a cent.
  • A small chat model (GPT-4.1 mini by default) — fills the named, length-capped text slots of the chosen templates. The writer is one constant, overridable with the SITEGEN_COPY_MODEL environment variable (any chat model id on the gateway, e.g. anthropic/claude-haiku-4.5). In a blind side-by-side on three churches Claude Haiku 4.5 read slightly warmer, but GPT-4.1 mini was close, roughly 4x cheaper and faster, so it is the default. A full page with all three layouts costs about 1.3 cents, around 80% of it the writer.
PhaseEndpointWhat happens
1POST /website/planPageClassifies the page type (home, visit, about…), then samples 10 candidate layouts from JEV's per-round probabilities (hero + section count → each section → closer), de-duplicates, has JEV score each for fit/flow/gaps, and returns the top 3 plus a writing voice and a suggestedStyle (palette + fonts). Candidates that share the same sections so far ask JEV an identical question, so rounds are memoized by prefix. A best score under 6 is logged as lowLayoutScore — that log is the backlog of templates worth adding. ~2s
2POST /website/writePage (one call per candidate)The writer fills the slot copy two sections per call, in parallel, and returns five hero headlines that JEV chooses between; JEV fact-checks every section; sections that fail, use a stock phrase, or re-tell an earlier section (shared 4-word runs, checked in code) are rewritten in parallel with the specific reason; a code scrub drops sentences with stock church-site phrases (unless the church's own description uses them); JEV picks photo subjects, icons and the hero's shape divider, and scores the result. Returns a ready-to-save section tree and a score. ~6–9s
3POST /content/pages/importTreeB1Admin writes only the best-ranked layout (the runner-up is a fallback if that write fails), saves it and opens the preview (~10s after Save)

Each phase is its own request so every call stays inside the API Gateway 29-second limit. Templates in SiteGenHelper.buildTree are fixed section + element trees from the catalog (text, row/column, card, iconFeature, faq, table, testimonial, textWithPhoto, box, map, sermons) and reference theme tokens (var(--accent), var(--lightAccent)…), so generated pages inherit the church's existing appearance settings. Adding a section template means adding its slot list to SECTIONS and its tree to buildTree; the unit test walks every template and validates the tree.

Inputs. Copy may only state facts from two sources: what the user typed, and churchContext.facts — records B1Admin gathers before planning (public service times and public group names, plus the church name and address). The same flags gate data-backed templates: times renders the live serviceTimes element when the church keeps service times in B1 (a typed table otherwise), groups and countdown are only offered when there is data behind them. JEV calls are hedged — a duplicate fires after 1.5s and the first answer wins — because the gateway occasionally stalls and the calls are nearly free.

Staying on the subject. The user's prompt is the subject of the page, not background about the church. planPage classifies the request (home, visit, about, ministries, give, contact, event, topic), and for event and topic pages the general-church templates (pastor note, sermons, ministries, groups, community impact, weekly times and countdown, video hero) are not even offered, while details (when / where / what to bring) and a date eventCountdown are. Both judges score on-topic-ness. B1Admin passes pageType from the plan into each writePage call.

Full pages from short requests. Pages have three to six middle sections, generous slot lengths, an intro line on card sections and a five-question FAQ, and the repair pass expands any section that comes back thin. Generation is one click: there are no follow-up questions. Where the request leaves out an ordinary detail a complete page needs (a start time, a room, what to bring, how to sign up), the writer fills it in with a plausible, modest choice for the church to edit. Because sections are written in parallel, those gaps are decided once, in planPage (assumedDetails, one small writer call that runs alongside layout sampling), and passed back into every writePage call through churchContext.assumedDetails, so one section cannot say 5:00 while another says 5:30. JEV repairs any section that contradicts the request, the church's records or those decided details. The decided details are not surfaced in the UI; the church reviews and edits the page like any other. Some things are never made up: names of people, phone numbers, email and web addresses, prices, statistics, the church's history, quotes attributed to people, and a day of the week for a date the request did not give one for.

Visuals. Templates never name their photos or icons. They leave slots open, and one generic pass (visualSlots → pickVisuals → applyVisuals in SiteGenHelper) walks the finished tree and fills every one: a section background or gallery entry marked auto:photo, an auto:icon, the hero's auto:divider, and, with no marker at all, any textWithPhoto, card or image element whose photo is empty. JEV picks each from the text beside it (a card's photo from that card's title and text; a background from the section's copy), with no subject repeated on a page. A new template therefore gets photos for free. Photos are Pexels search subjects emitted as pexels:<term> placeholders that B1Admin resolves through POST /content/stock/search; a client that does not send resolvesPhotos gets a built-in hero image, flat colored bands and photo-less cards instead. There are deliberately no portrait subjects and the pastor template carries no photo: a stock stranger must never stand in for a real person. For a church with no pages yet, B1Admin applies suggestedStyle to the global styles; existing sites keep their look.

Other endpoints​

info

The SectionToolbar rewrite button and the pages-list "Generate Site" button in B1Admin remain commented out client-side. The AskApi endpoints below still respond; only that UI is hidden.

EndpointPurpose
POST /website/generatePageOutline → generateSectionThe original two-step page flow (outline, then one LLM call per section emitting element JSON). Superseded in B1Admin by planPage/writePage because of cost; kept for API consumers
POST /website/generateSiteWhole-site generation. Two-phase by design: a planOnly: true call returns just the multi-page plan (one fast model call), then the client requests full content — keeping every request inside the Lambda/API-Gateway timeout
POST /website/rewriteSectionStructure-preserving rewrite: the model may only change text-bearing answers. A recursive structure signature (ids + types + order) is compared before and after; any mismatch returns the original section with fallback: true instead of corrupted structure
POST /website/generateAltTextVision call over up to 20 image URLs; returns concise alt text (≤125 chars, "photo of" prefixes stripped)
POST /website/generateMetaDescriptionOne SEO meta description (≤155 chars) from the page's text content — wired to the Generate button on B1Admin's page settings

Prompts for these endpoints are markdown files under AskApi/config/instructions/, including the element catalog the model generates from. Two design points keep the catalog honest: the client passes availableElementTypes on every request (the prompt may only use types from that list — the server never hardcodes the full set), and the API's MCP describe_page_builder tool carries the same guide for AI agents working through MCP. Models are Anthropic Claude via OpenRouter — 3.5 Haiku for section content (latency), 3.5 Sonnet for outlines, site plans, and vision — with an OpenAI fallback when no OpenRouter key is configured.

Conversational forms​

Forms (membership module) gained a conversational mode aimed at connect-card-style pages. Four columns on forms drive it: displayMode (standard | conversational), autoCreatePerson, followUpSubject, followUpBody.

  • Rendering — apphelper's FormSubmissionEdit switches to the ConversationalForm component (one question at a time) when displayMode is conversational; B1App's form page passes the mode through. Same submission payload either way.
  • Auto-create person — on submission with autoCreatePerson set, ConversationalFormHelper.findOrCreatePerson dedups by email (case-insensitive) and otherwise creates a household + person with membershipStatus: "Guest", then links the submission to that person.
  • Follow-up email — when a subject and body are set, the submitter gets a templated email (with {firstName} / {churchName} tokens) through the existing transactional path (TransactionalEmailHelper), never the notification digest door. Both side-effects are non-fatal: a failure never loses the submission.

The four fields are set via the API today; the B1Admin form editor does not expose them yet.

Public-site cache​

B1App's public render path caches church-tagged fetches (next: { revalidate: 300, tags: [sdSlug] } in production; 0 in dev) so a live page can stay stale for up to five minutes after a ContentApi write. POST /api/revalidate/{sdSlug} on B1App calls revalidateTag(sdSlug) and is the only way to drop that cache early.

Two writers hit it:

  1. B1Admin — clearSiteCache() in B1Admin/src/site/siteCache.ts POSTs after editor saves. It prefers the active site's subdomain (a secondary site must bust that tag, not the church's default).
  2. Api — Content mutations that never go through B1Admin (API keys, MCP, AI) fire SiteCacheHelper.bump(churchId) from the content controllers. The helper resolves the church subdomain via SubDomainHelper and POSTs {b1AppRoot}/api/revalidate/{sd}. Failures are swallowed so an unreachable B1App cannot fail a save.

Controllers that bump: pages (save, delete, duplicate, publish, discard, unpublish, AI temp), sections, elements, blocks, links, global styles, posts, and redirects. Dev b1AppRoot is http://{subdomain}.localtest.me:3301; demo/staging/prod use https://{subdomain}.b1.church.