Visual drag-and-drop landing page builder powered by Puck v0.20 (@measured/puck). School admins on the starter plan and above can create fully customizable landing pages using 32 pre-built components across 4 categories, with 8 starter templates.
landing_pages table stores all page data per tenant:
| Column | Type | Description |
|---|---|---|
page_id |
UUID | Primary key |
tenant_id |
UUID | Tenant ownership (FK → tenants(id), ON DELETE CASCADE) |
title |
TEXT | Page display name |
slug |
TEXT | URL slug (home for homepage) — UNIQUE (tenant_id, slug) |
is_published |
BOOLEAN | Whether the page is published & served publicly (default false) |
puck_data |
JSONB | Puck editor state (component tree, zones, root props) — default '{}' |
created_at |
TIMESTAMPTZ | Creation timestamp |
updated_at |
TIMESTAMPTZ | Last-updated timestamp |
Code vs DB naming: the server actions in
app/actions/admin/landing-pages.tsmap the raw columns to a friendlierLandingPageinterface viamapRow():page_id → id,title → name, andis_published → is_activeplus a derivedstatus('published'whenis_published, else'draft'). The DB has noname,status, oris_activecolumns — those exist only in the TS interface.
RLS policies: tenant members can read; tenant admins can manage (the landing_pages table
enables RLS, but all CRUD in the server actions goes through createAdminClient() which bypasses
RLS, with an explicit tenant_id ownership check on every write).
Image uploads go to the landing-page-assets Supabase Storage bucket. Upload handled by app/actions/admin/landing-page-assets.ts.
- Editor (
<Puck>) — admin builds pages with drag-and-drop, savespuck_dataJSONB to the database - Renderer (
<Render>) — public pages renderpuck_datathroughPuckPageRenderer - Both use
createPuckConfig(t)to get a translated Puck config instance
| Component | Description |
|---|---|
Heading |
Configurable heading (h1-h6) with alignment |
TextBlock |
Rich text / paragraph block |
Image |
Image with alt text, sizing, and alignment |
ButtonBlock |
CTA button with link, variant, and size options |
Video |
Embedded video (YouTube, Vimeo, etc.) |
Divider |
Horizontal rule / separator |
Spacer |
Vertical spacing block |
IconBlock |
Icon display from icon library |
BadgeBlock |
Styled badge / tag element |
| Component | Description |
|---|---|
Section |
Full-width container with background, padding — uses DropZone for nested content |
Columns |
Multi-column layout — uses DropZone per column |
Container |
Constrained-width wrapper — uses DropZone |
Grid |
CSS grid layout — uses DropZone for cells |
Card |
Styled card container — uses DropZone for inner content |
| Component | Description |
|---|---|
HeroBlock |
Main banner with title, subtitle, dual CTA, background image |
FeaturesGrid |
Feature cards in configurable grid columns |
CourseGrid |
Course cards (can auto-populate from DB) |
PricingTable |
Product/plan pricing cards |
TestimonialGrid |
Student testimonial / quote cards |
FaqAccordion |
Accordion-style FAQ section |
StatsCounter |
Metrics display (value + label pairs) |
CtaBlock |
Call-to-action banner with customizable style |
ContactForm |
Contact / inquiry form |
LogoCloud |
Partner / sponsor logo strip |
Banner |
Announcement / notification banner |
TeamGrid |
Instructor / team member showcase |
ImageGallery |
Image gallery with grid layout |
SocialProof |
Social proof indicators (reviews, ratings, etc.) |
| Component | Description |
|---|---|
Header |
Page header with logo, nav links |
Footer |
Page footer with links, copyright |
Navbar |
Navigation bar variant |
BreadcrumbBlock |
Breadcrumb navigation trail |
Defined in lib/puck/templates/index.ts. Each template is a PuckTemplate object containing pre-built puck_data. When applied, deepCloneWithFreshIds() generates unique component IDs to avoid collisions.
| Template | Description |
|---|---|
| Blank | Empty canvas with just a Header and Footer |
| Modern Academy | Full-featured school page (hero, features, courses, testimonials, CTA, FAQ) |
| Minimal | Clean, minimal layout focused on content |
| Bold Creator | Bold typography and layout for solo creators |
| Course Catalog | Focused on showcasing courses |
| About | About / team page layout |
| Contact | Contact page with form and info |
| FAQ | Dedicated FAQ page |
lib/puck/
config.ts # Puck config: createPuckConfig(t) + static puckConfig
components/
primitives/ # 9 primitive components
heading.tsx, text.tsx, image.tsx, button-block.tsx,
video.tsx, divider.tsx, spacer.tsx, icon-block.tsx, badge-block.tsx
layout/ # 5 layout components (use DropZone)
section.tsx, columns.tsx, container.tsx, grid.tsx, card.tsx
lms/ # 14 LMS-specific blocks
hero-block.tsx, features-grid.tsx, course-grid.tsx,
pricing-table.tsx, testimonial-grid.tsx, faq-accordion.tsx,
stats-counter.tsx, cta-block.tsx, contact-form.tsx,
logo-cloud.tsx, banner.tsx, team-grid.tsx,
image-gallery.tsx, social-proof.tsx
navigation/ # 4 navigation components
header.tsx, footer.tsx, navbar.tsx, breadcrumb-block.tsx
templates/
index.ts # 8 built-in templates + deepCloneWithFreshIds()
components/admin/landing-page/
puck-editor.tsx # 'use client' — wraps <Puck> editor
template-picker.tsx # Template selection dialog
preview-banner.tsx # Banner shown on preview pages
components/public/landing-page/
puck-page-renderer.tsx # 'use client' — wraps <Render> for public pages
app/actions/admin/
landing-pages.ts # Server actions: CRUD, publish, activate, duplicate
landing-page-assets.ts # Asset upload to Supabase Storage
lib/landing-pages/
types.ts # Minimal types for default school navbar/footer
# (HeaderSettings, FooterSettings)
app/[locale]/dashboard/admin/landing-page/
page.tsx # Admin landing page management
preview/[pageId]/
page.tsx # Full-page preview (uses createAdminClient)
| Route | Description |
|---|---|
/dashboard/admin/landing-page |
Landing page list, create, manage |
/dashboard/admin/landing-page/preview/[pageId] |
Full-page preview with preview banner |
| Route | Description |
|---|---|
/ |
Homepage — renders the active landing page for the tenant |
/p/[slug] |
Additional pages by slug |
The Puck config supports internationalization through createPuckConfig(t):
- Component labels are translated via
t('components.ComponentName') - Category titles are translated via
t('categories.categoryName') - Field labels are translated via
translateFieldLabel()which looks upt('fieldLabels.Label') - Field option labels are also translated
- The static
puckConfigexport uses English defaults for non-i18n contexts
Both PuckEditor and PuckPageRenderer call useTranslations('puck') and pass the translator to createPuckConfig(t).
- Row per page with a status badge (
draft/published) - Create from template picker or blank canvas (page-type picker → template picker)
- Actions: Publish/Unpublish, Duplicate, Delete (via the
…menu) - Slug is unique per tenant (
UNIQUE (tenant_id, slug)) - Cannot delete a published page — unpublish it first (
deleteLandingPagethrows otherwise)
- Full Puck drag-and-drop editor with component sidebar
- Components organized into 4 categories (Primitives, Layout, LMS Blocks, Navigation)
- Top bar: page name, status badge, Save button, Publish button, Back navigation
- Uses
usePuck()hook to access editor state for save operations - Save writes
puck_dataJSONB to the database viaupdateLandingPage()server action - Publish calls
publishLandingPage()server action
- Full-page preview at
/dashboard/admin/landing-page/preview/[pageId] - Shows a "Preview Mode" banner with page status and "Back to Editor" link
- Uses
createAdminClient()so draft pages are previewable (bypasses RLS) - Verifies
tenant_idmatches the admin's tenant for isolation
FEATURE_REQUIRED_PLAN.landing_pages = 'starter'inlib/plans/features.ts- Admin route wrapped with
<FeatureGate feature="landing_pages"> - Free-plan schools see an upgrade prompt instead of the builder
The public homepage (app/[locale]/(public)/page.tsx) renders landing pages as follows:
- Detect tenant from subdomain
- If tenant plan is
starter/pro/business/enterprise:- Query
landing_pagesfor thehomeslug withis_published = true - If found, render
<PuckPageRenderer data={puck_data} />
- Query
- Otherwise, fall back to the default
<SchoolLandingPage>(product grid) - Default tenant (
00000000-0000-0000-0000-000000000001) always shows the platform homepage
Additional pages are served by app/[locale]/(public)/p/[slug]/page.tsx, which queries
landing_pages by slug + is_published = true and renders the same <PuckPageRenderer>.
- Layout components use
DropZonefrom@measured/puckto enable nested content. SinceDropZoneis not available in@measured/puck/rsc, any component using it must be a client component. PuckPageRenderermust be'use client'because<Render>internally usesDropZonewhich requires client-side React context.deepCloneWithFreshIds()generates unique random IDs when applying templates, preventing component ID collisions across pages.- Server actions use
puck_data(not the oldsections/settingscolumns which were dropped). createAdminClient()is used for preview routes so admins can preview draft pages that RLS would otherwise block.- Asset uploads go through
app/actions/admin/landing-page-assets.tsto thelanding-page-assetsSupabase Storage bucket.
- Publishing flips
is_published = true; unpublishing flips it back tofalse(a page stops being served publicly once unpublished) - Duplicating creates a new unpublished copy; its slug gets a
-copy-<base36 timestamp>suffix to stay unique - Templates are applied client-side with fresh IDs via
deepCloneWithFreshIds() - Slug validation rejects reserved slugs (
api,dashboard,admin,auth,login,signup,register) and sanitizes input to[a-z0-9-] - Page name (
title) max length: 255 characters; slug max length: 100 characters - Default slug for new pages is
home