Six-stage plan for user-created content pages at top-level URLs. System pages keep dedicated LiveViews, custom pages use a single generic LiveView with portable blocks. Includes navigation management, SEO, and auto-redirects on slug change. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
12 KiB
Custom CMS pages
Status: Planned See also: page-builder.md (existing page editor plan)
Six stages, ordered by priority. Each stage is independently shippable.
Context
The page builder currently works with a fixed set of 14 system slugs (home, about, cart, etc.). Each maps to a dedicated LiveView with its own event handlers. The renderer, block system, and editor hook are already fully generic — they work with any slug. The constraints are localised to a handful of hardcoded lists.
This plan adds user-created content pages at arbitrary top-level URLs using a catch-all-last routing strategy. System pages keep their dedicated LiveViews. Custom pages use a single generic LiveView with portable blocks only. Navigation becomes data-driven.
Key architectural decision: system pages (cart, PDP, collection, etc.) stay as dedicated LiveViews because their blocks need custom event handlers. Custom pages only get portable blocks (hero, image_text, content_body, featured_products, etc.) via the existing allowed_on: :all filtering. This is the same approach WordPress, Shopify, and every CMS uses — reserved system routes, catch-all for everything else.
Stage 1: Data model + context (unblocks everything)
Goal: Custom pages can be created, stored, and retrieved. No UI yet.
1a. Migration
New file: priv/repo/migrations/TIMESTAMP_add_custom_page_fields.exs
alter table(:pages) do
add :type, :string, default: "system" # "system" | "custom"
add :published, :boolean, default: true
add :meta_description, :string
add :show_in_nav, :boolean, default: false
add :nav_label, :string # display name in nav (defaults to title)
add :nav_position, :integer # ordering within nav
end
No index changes — the existing unique index on slug covers custom pages.
1b. Page schema
File: lib/berrypod/pages/page.ex
- Rename
@valid_slugs→@system_slugs(line 8). Public accessorsystem_slugs/0. - Add
@reserved_paths— every first-segment used by system routes, preventing slug collisions:~w(about delivery privacy terms contact collections products cart search checkout orders coming-soon admin health image_cache images favicon sitemap.xml robots.txt setup dev) - Add new fields to schema:
:type,:published,:meta_description,:show_in_nav,:nav_label,:nav_position. - Split changeset:
system_changeset/2— existing validation withvalidate_inclusion(:slug, @system_slugs).custom_changeset/2— URL-safe slug format (~r/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/), exclusion from@system_slugs ++ @reserved_paths, setstype: "custom". Required::slug,:title.
- Add
system_slug?/1andreserved_path?/1helpers.
1c. Defaults fallback
File: lib/berrypod/pages/defaults.ex
Add catch-all clauses:
defp title(slug), do: slug |> String.replace("-", " ") |> String.capitalize()defp blocks(_slug), do: []
Only fires for unknown slugs. System slugs still get their specific defaults.
1d. Pages context
File: lib/berrypod/pages.ex
- Rename
list_pages/0→list_system_pages/0(iteratesPage.system_slugs()). - Add
list_custom_pages/0— queries DB fortype: "custom", ordered bytitle. - Add
list_all_pages/0—list_system_pages() ++ list_custom_pages(). - Add
create_custom_page/1— takes%{slug:, title:}, usescustom_changeset/2, inserts. - Add
delete_custom_page/1— deletes by slug, only iftype == "custom". Invalidates cache. - Add
update_custom_page/2— updates title, slug, meta, published, nav fields. Slug change auto-creates redirect viaRedirects.create_auto/1. - Modify
save_page/2— usesystem_changesetfor system slugs,custom_changesetfor custom. - Modify
reset_page/1— for custom pages, delete entirely (no defaults to fall back to). - Update
page_to_map/1(line 126) — include new fields. get_page_from_db/1(line 37) — returnnilfor non-system slugs not in DB (don't callDefaults.for_slug/1).
1e. Page cache
File: lib/berrypod/pages/page_cache.ex
warm/0(line 50) — after system slugs, also query DB for all custom pages and cache them.
1f. Tests
File: test/berrypod/pages_test.exs
create_custom_page/1— valid slug, duplicate, reserved slug, system slug collision.delete_custom_page/1— custom page, system page (refuse).update_custom_page/2— title change, slug change (verify redirect created).list_custom_pages/0— empty, with pages, ordering.get_page/1for custom slug — nil when not created, page when created.- Update existing
list_pagestest (nowlist_system_pages).
Files: page.ex, pages.ex, defaults.ex, page_cache.ex, migration, pages_test.exs
Est: 1.5h
Stage 2: Routing + custom page LiveView
Goal: Custom pages accessible at /:slug on the shop.
2a. New LiveView
New file: lib/berrypod_web/live/shop/custom_page.ex
Follows Shop.Content pattern — loads page in handle_params so PageEditorHook deferred init works:
def mount(_params, _session, socket), do: {:ok, socket}
def handle_params(%{"slug" => slug}, _uri, socket) do
page = Pages.get_page(slug)
if is_nil(page) or page[:type] != "custom" or page[:published] != true do
raise BerrypodWeb.NotFoundError # or however 404s are handled
end
{:noreply,
socket
|> assign(:page, page)
|> assign(:page_title, page.title)
|> assign(:page_description, page[:meta_description])
|> assign(:og_url, BerrypodWeb.Endpoint.url() <> "/#{page.slug}")}
end
def render(assigns), do: ~H"<BerrypodWeb.PageRenderer.render_page {assigns} />"
2b. Router
File: lib/berrypod_web/router.ex
Add as the last route in public_shop live_session (after line 88):
live "/:slug", Shop.CustomPage, :show
Only matches single-segment paths. Multi-segment paths (/collections/all, /orders/123) matched by earlier explicit routes. Route order guarantees system routes always win.
2c. Tests
New file: test/berrypod_web/live/shop/custom_page_test.exs
- Published custom page renders at
/:slug. - Unpublished page → 404.
- Nonexistent slug → 404.
- System routes unaffected (
/about,/cartstill hit their LiveViews). - Page editor works on custom pages (
?edit=true). - SEO assigns set correctly.
Files: router.ex, new custom_page.ex, new test file
Est: 1h
Stage 3: Admin CRUD for custom pages
Goal: Admins can create, edit, and delete custom pages.
3a. Admin pages index
File: lib/berrypod_web/live/admin/pages/index.ex
- "Custom pages" section below system page groups.
- Load via
Pages.list_custom_pages(). - "Create page" button →
/admin/pages/new. page_icon/1catch-all:defp page_icon(_), do: "hero-document".- "Draft" badge when
published == false.
3b. Page creation flow
File: lib/berrypod_web/live/admin/pages/editor.ex
%{"slug" => "new"}in mount → creation mode.- Form: title (required), auto-generated slug (editable), meta description.
- Submit:
Pages.create_custom_page/1→ redirect to/admin/pages/#{slug}. - Slug generation:
title |> String.downcase() |> String.replace(~r/[^a-z0-9]+/, "-") |> String.trim("-").
3c. Page settings for custom pages
File: lib/berrypod_web/live/admin/pages/editor.ex
- Detect custom via
page.type == "custom". - Editable settings section: title, slug, meta description, published toggle, show in nav, nav label.
- Slug changes call
Pages.update_custom_page/2(auto-redirect).
3d. Delete page
- Custom pages only: "Delete page" button with confirmation.
Pages.delete_custom_page/1→ redirect to/admin/pages.
3e. Admin route
File: lib/berrypod_web/router.ex
live "/pages/new", Admin.Pages.Editor, :new— BEFORE existing/pages/:slug.
3f. Tests
- Create page: form validation, slug generation, redirect.
- Edit title/slug/description.
- Delete custom page.
- Custom pages in page list.
- Cannot delete system pages.
- Cannot create with reserved slugs.
Files: admin/pages/index.ex, admin/pages/editor.ex, router.ex, tests, admin CSS
Est: 2.5h
Stage 4: Navigation management
Goal: Header, footer, and mobile nav are data-driven and configurable.
4a. Data model
Store as JSON settings (existing Settings system):
Settings.put_setting("header_nav", Jason.encode!(items), "json")
Settings.put_setting("footer_nav", Jason.encode!(items), "json")
Each item: %{"label" => "Home", "href" => "/", "slug" => "home"}
slug is optional — used for active-page highlighting. Default items match current hardcoded nav.
4b. Load in ThemeHook
File: lib/berrypod_web/theme_hook.ex
- Load
header_nav/footer_navsettings inmount_theme. - Assign
@header_nav_items,@footer_nav_items. - Fall back to default items if not configured.
4c. Data-driven rendering
File: lib/berrypod_web/components/shop_components/layout.ex
- Header (lines 714-737): Replace hardcoded items with
:forloop over@header_nav_items. - Footer "Help" column (lines 573-653): Replace with loop over
@footer_nav_items. - Mobile bottom nav (lines 184-227): Keep fixed for now (icon slots are a bigger UX decision).
- Add
header_nav_itemsandfooter_nav_itemsto@layout_keys(line 51).
4d. Admin nav editor
New file: lib/berrypod_web/live/admin/navigation.ex
- Route:
/admin/navigation - Header + footer item lists with label/href inputs.
- Move up/down buttons (same pattern as block editor).
- "Add item" + "Add page" (dropdown of all pages).
- Save → settings. "Reset to defaults" button.
4e. Admin sidebar link
File: lib/berrypod_web/components/layouts/admin.html.heex — add "Navigation" link.
4f. Tests
- Nav renders from settings.
- Defaults match current hardcoded items.
- Admin CRUD for nav items.
Files: layout.ex, theme_hook.ex, admin layout, new navigation.ex, router, CSS, tests
Est: 3h
Stage 5: SEO + redirects
Goal: Custom pages are search-engine friendly.
5a. Sitemap
File: lib/berrypod_web/controllers/seo_controller.ex
custom_pages =
Pages.list_custom_pages()
|> Enum.filter(& &1.published)
|> Enum.map(fn p -> {"/#{p.slug}", "weekly", "0.6"} end)
all_pages = static_pages ++ category_pages ++ product_pages ++ custom_pages
5b. Auto-redirect on slug change
Already wired in stage 1d. Verify and test. Existing redirect plug handles all paths.
5c. Draft enforcement
- Admin preview: allow viewing unpublished pages if
is_admin, show "Draft" banner. - Sitemap/nav exclude unpublished.
5d. Tests
- Sitemap includes published, excludes unpublished.
- Slug change creates 301 redirect.
- Draft pages 404 for visitors, render for admins.
Files: seo_controller.ex, pages.ex, custom_page.ex, tests
Est: 1h
Stage 6: Polish (defer)
- Page templates — blank, content page, landing page starter layouts.
- New block types — spacer, rich text, image gallery, video embed (all
allowed_on: :all). - Bulk operations — publish/unpublish multiple, duplicate page.
- Page ordering — position field, move up/down in admin list.
Est: 3-4h total, incremental
Key files
| File | Stages | Change |
|---|---|---|
lib/berrypod/pages/page.ex |
1 | Schema, split changeset |
lib/berrypod/pages.ex |
1, 5 | New CRUD functions |
lib/berrypod/pages/defaults.ex |
1 | Catch-all clauses |
lib/berrypod/pages/page_cache.ex |
1 | Warm custom pages |
lib/berrypod_web/router.ex |
2, 3 | Catch-all + admin routes |
lib/berrypod_web/live/shop/custom_page.ex |
2, 5 | New LiveView |
lib/berrypod_web/live/admin/pages/index.ex |
3 | Custom pages section |
lib/berrypod_web/live/admin/pages/editor.ex |
3 | Create/edit/delete |
lib/berrypod_web/components/shop_components/layout.ex |
4 | Data-driven nav |
lib/berrypod_web/theme_hook.ex |
4 | Load nav settings |
lib/berrypod_web/live/admin/navigation.ex |
4 | New admin page |
lib/berrypod_web/controllers/seo_controller.ex |
5 | Sitemap |
assets/css/admin/components.css |
3, 4 | Admin styles |
Verification
After each stage: mix precommit, browser check at localhost:4000.
Full end-to-end: create page from admin → renders at /:slug → edit with live editor → change slug (redirect works) → add to nav → check sitemap → unpublish (404 for visitors).