- fix Site tab not loading theme state on direct URL navigation - fix nav editor showing "Custom URL" for page links (detect by URL match) - add Home option to nav page picker - mark editor-reorganisation plan as complete - add dynamic-url-customisation and draft-publish-workflow plans Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
18 KiB
Dynamic URL Customisation
Allow shop owners to customise all URL paths — both system pages (e.g., /cart → /basket) and route prefixes (e.g., /products/ → /p/, /collections/ → /shop/). Automatic redirects from old URLs.
Design Summary
Approach: Store URL customisations in Settings (JSON). Create BerrypodWeb.R module (short name for ergonomic use) for runtime path lookups with ETS caching. Refactor router to use flexible catch-all patterns.
Two types of customisation:
- Page slugs — Single-segment paths like
/cart,/about,/contact - Route prefixes — Multi-segment paths like
/products/:id,/collections/:slug
Relationship with versioning: URLs are versioned data (see draft-publish-workflow.md). When a page is published with a new URL, the old URL creates a redirect. When rolling back to an old version, the URL reverts too and the redirect chain updates. Redirects are essentially a log of URL history, always pointing to the current canonical URL.
URL Classification
| Route Type | Default | Renameable | Unpublishable | Notes |
|---|---|---|---|---|
| home | / |
No | No | Root must be / |
| about | /about |
Yes | Yes | Content page |
| delivery | /delivery |
Yes | Yes (warning) | Legal page |
| privacy | /privacy |
Yes | Yes (warning) | Legal page |
| terms | /terms |
Yes | Yes (warning) | Legal page |
| contact | /contact |
Yes | Yes | Content page |
| cart | /cart |
Yes | No | Required for checkout |
| search | /search |
Yes | Yes | Small catalogues may not need search |
| checkout_success | /checkout/success |
Yes | No | Stripe callback |
| orders | /orders |
Yes | Yes | Some shops prefer email-only updates |
| order_detail | /orders/:num |
Prefix | No | Individual orders still accessible |
| collections | /collections/:slug |
Prefix | No | Route required, individual items can be unpublished |
| products | /products/:id |
Prefix | No | Route required, individual items can be unpublished |
Unpublishing consequences:
- Search unpublished → hide search icon in header,
/searchreturns 404 - Orders unpublished → hide order lookup form in contact,
/ordersreturns 404 (individual order URLs still work) - Legal pages unpublished → warning shown, links removed from footer
Implementation Phases
Phase 1: Data Model (1.5h)
Two storage locations:
- Page slugs — Stored on individual Page records (
url_slugfield) - Route prefixes — Stored in Settings JSON (
url_prefixeskey)
Migration: Add url_slug column to pages table.
alter table(:pages) do
add :url_slug, :string
end
create unique_index(:pages, [:url_slug], where: "url_slug IS NOT NULL")
Schema changes in lib/berrypod/pages/page.ex:
- Add
field :url_slug, :string - Add
effective_url/1helper: returnsurl_slug || slug - Add validation: url_slug format, uniqueness, no conflicts with other slugs
Settings changes for route prefixes:
# In Settings context, add url_prefixes with defaults
%{
"url_prefixes" => %{
"products" => "products", # /products/:id
"collections" => "collections", # /collections/:slug
"orders" => "orders" # /orders/:num
}
}
Validation: prefix format (lowercase, hyphens, no slashes), uniqueness across prefixes
Phase 2: Routes Module (2.5h)
New file: lib/berrypod_web/r.ex
Short module name R for ergonomic replacement of ~p paths:
defmodule BerrypodWeb.R do
@moduledoc """
Runtime URL paths with ETS caching. Short name for ergonomic use.
Usage:
R.cart() # => "/basket" (or whatever it's configured to)
R.product(123) # => "/p/123"
R.url(R.product(123)) # => "https://shop.com/p/123"
"""
# === Single-segment pages (each gets its own function) ===
def home, do: "/"
def cart, do: "/" <> slug(:cart)
def about, do: "/" <> slug(:about)
def contact, do: "/" <> slug(:contact)
def search, do: "/" <> slug(:search)
def delivery, do: "/" <> slug(:delivery)
def privacy, do: "/" <> slug(:privacy)
def terms, do: "/" <> slug(:terms)
def checkout_success, do: "/" <> slug(:checkout_success)
# === Prefixed paths ===
def product(id), do: "/" <> prefix(:products) <> "/" <> to_string(id)
def collection(slug), do: "/" <> prefix(:collections) <> "/" <> slug
def order(num), do: "/" <> prefix(:orders) <> "/" <> num
# === Full URLs (for OG tags, emails, sitemaps) ===
def url(path), do: BerrypodWeb.Endpoint.url() <> path
# === Reverse lookups for router ===
def page_type_from_slug(slug)
# Returns {:page, :cart} | {:page, :about} | {:custom, page} | nil
def prefix_type_from_segment(segment)
# Returns :products | :collections | :orders | nil
# === Cache management ===
def start_cache/0 # Create ETS table
def warm_cache/0 # Load all slugs + prefixes from DB
def invalidate/1 # Clear specific key on change
def invalidate_all/0 # Clear everything (for prefix changes)
# === Private helpers ===
defp slug(type), do: get_cached_slug(type)
defp prefix(type), do: get_cached_prefix(type)
end
Comparison with ~p:
# Before # After
~p"/cart" R.cart()
~p"/products/#{id}" R.product(id)
~p"/collections/#{slug}" R.collection(slug)
BerrypodWeb.Endpoint.url() <> R.url(R.product(id))
~p"/products/#{id}"
ETS cache structure:
# Page slugs: {:slug, :cart} => "basket"
# Prefixes: {:prefix, :products} => "p"
# Reverse lookups populated on warm
Startup: Add to application supervision tree to warm cache on boot.
Phase 3: Router Refactor (2.5h)
Changes to lib/berrypod_web/router.ex:
Only home stays as a static route:
live "/", Shop.Page, :home
Everything else uses catch-all patterns:
# Two-segment catch-all for prefixed routes (products, collections, orders)
live "/:prefix/:id_or_slug", Shop.Page, :dynamic_prefixed
# Single-segment catch-all for page slugs and custom pages
live "/:slug", Shop.Page, :dynamic_page
Order matters: Two-segment route must come before single-segment.
Changes to lib/berrypod_web/live/shop/page.ex:
Handle :dynamic_prefixed:
def handle_params(%{"prefix" => prefix, "id_or_slug" => id_or_slug}, _uri, socket) do
case R.prefix_type_from_segment(prefix) do
:products -> dispatch_to(:product, %{"id" => id_or_slug}, socket)
:collections -> dispatch_to(:collection, %{"slug" => id_or_slug}, socket)
:orders -> dispatch_to(:order_detail, %{"order_number" => id_or_slug}, socket)
nil -> {:noreply, assign(socket, :page_not_found, true)}
end
end
Handle :dynamic_page:
def handle_params(%{"slug" => slug}, _uri, socket) do
case R.page_type_from_slug(slug) do
{:page, type} -> dispatch_to(type, %{}, socket)
{:custom, page} -> dispatch_to(:custom, %{"page" => page}, socket)
nil -> {:noreply, assign(socket, :page_not_found, true)}
end
end
Phase 4: Update Hardcoded References (4h)
Import R in web modules:
Add to lib/berrypod_web.ex in the relevant quote blocks:
alias BerrypodWeb.R
This makes R.cart(), R.product(id), etc. available everywhere.
Page path references (~50 places):
Controllers:
checkout_controller.ex:~p"/cart"→R.cart()cart_controller.ex: Same patterncontact_controller.ex:~p"/contact"→R.contact()order_lookup_controller.ex: Same pattern
Components:
layout.ex: Header/footer cart links →R.cart()page_renderer.ex: Inline links in content- All shop templates with
/cart,/contact, etc.
Prefixed path references (~40 places):
Product links:
product.excomponents:~p"/products/#{id}"→R.product(id)page_renderer.exproduct blocks- Cart drawer product links
- Order confirmation product links
Collection links:
layout.exnav links →R.collection(slug)collection.excomponents- Breadcrumbs
Order links:
- Order confirmation emails →
R.order(num) - Admin order links (keep
/admin/ordersseparate) - Customer order lookup
SEO controller:
- Make sitemap dynamic by querying R module for all current URLs
- Include all custom prefixes
Page content modules:
- Update OG URLs to use
R.url(R.product(id)), etc.
Phase 5: Redirect Integration (1.5h)
Page slug changes in lib/berrypod/pages.ex:
When page URL changes (via publish with new URL):
- Create auto-redirect from old path to new path via
Redirects.create_auto/1 - Invalidate Routes cache for that page
- Update nav items referencing old URL (Site context)
On rollback (when draft-publish-workflow is implemented):
- Check if rolled-back version has different URL
- If so, update/remove redirects to point to restored canonical URL
- Invalidate Routes cache
Prefix changes in lib/berrypod/settings.ex:
When a prefix changes (e.g., products → p):
- Create individual redirects for each existing item using that prefix
- Query all products/collections/orders that exist at change time
- Create redirect:
/products/123→/p/123for each - Invalidate entire Routes cache
- Update nav items referencing old prefix
Why per-item redirects, not pattern redirects:
- Pattern redirects (
/products/*→/p/*) would need to stay forever (can't prune) - Per-item redirects are prunable after they stop being hit
- New items created after the change never existed under the old prefix — nothing to redirect
- Requests to
/products/new-item(where new-item was created post-change) are just 404s for URLs that never existed
Redirect creation helper:
def create_prefix_redirects(old_prefix, new_prefix, type) do
items = case type do
:products -> Products.list_all_product_ids()
:collections -> Products.list_all_collection_slugs()
:orders -> Orders.list_all_order_numbers()
end
Enum.each(items, fn id_or_slug ->
Redirects.create_auto(%{
from_path: "/#{old_prefix}/#{id_or_slug}",
to_path: "/#{new_prefix}/#{id_or_slug}"
})
end)
end
Note: For shops with many products, this could create thousands of redirects. Consider:
- Running via Oban job to avoid blocking the request
- Progress indicator in UI
- Batch inserts for performance
Phase 6: Admin UI (3h)
Page settings in page_renderer.ex page_settings_section:
For renameable pages (about, contact, cart, search, legal, custom):
- Show URL slug input (editable)
- Live URL preview:
yourshop.com/basket - Redirect notice: "Old URL will redirect here"
For home page:
- Show URL slug input (disabled)
- Tooltip: "Home page must be at /"
Published toggle:
- Enabled for: custom pages, about, contact, search, orders
- Enabled with warning for: legal pages (delivery, privacy, terms)
- Disabled for: home, cart, checkout_success
Unpublish warnings:
- Search: "Search icon will be hidden from header"
- Orders: "Order lookup will be hidden from contact page"
- Legal pages: "Link will be removed from footer. Consider legal requirements."
Route prefix settings in Site tab site_editor.ex:
New "URL Prefixes" section:
┌─────────────────────────────────────────┐
│ URL Prefixes │
├─────────────────────────────────────────┤
│ Products: [p________] /p/my-product │
│ Collections: [shop_____] /shop/t-shirts │
│ Orders: [orders___] /orders/12345 │
│ │
│ ⓘ Changing prefixes creates redirects │
│ for all existing items. │
└─────────────────────────────────────────┘
Each prefix field:
- Text input with pattern validation
- Live URL preview with example slug
- Info text about redirect creation
- Confirmation dialog for changes (since it creates many redirects)
Phase 7: Testing (2.5h)
Routes module unit tests:
- ETS cache creation and warming
- Slug lookups (default and custom)
- Prefix lookups (default and custom)
- Reverse lookups (slug → page type, segment → prefix type)
- Cache invalidation (single key and full)
Integration tests:
- Page slug change creates redirect
- Prefix change creates bulk redirects
- Old URLs return 301 to new URLs
- Router dispatches correctly with custom slugs/prefixes
E2E flow tests:
- Change
/cartto/basket:/basketloads cart page/cartredirects to/basket- Checkout cancel_url uses
/basket - Stripe checkout flow completes
- Change
/products/to/p/:/p/123loads product page/products/123redirects to/p/123- Product links use new prefix
SEO tests:
- Sitemap uses current URLs
- OG tags use current URLs
- Canonical URLs use current URLs
Files to Modify
| File | Changes |
|---|---|
priv/repo/migrations/xxx_add_url_slug.exs |
New migration for url_slug |
lib/berrypod/pages/page.ex |
Add url_slug field, validations, effective_url/1 |
lib/berrypod/pages.ex |
Redirect creation on slug change |
lib/berrypod/settings.ex |
Add url_prefixes to settings, prefix change handling |
lib/berrypod_web/r.ex |
New module (ETS cache, path functions, reverse lookups) |
lib/berrypod_web.ex |
Add alias BerrypodWeb.R to controller/live/component quotes |
lib/berrypod_web/router.ex |
Replace static routes with catch-all patterns |
lib/berrypod_web/live/shop/page.ex |
Handle :dynamic_page and :dynamic_prefixed |
lib/berrypod_web/controllers/checkout_controller.ex |
~p"/cart" → R.cart() |
lib/berrypod_web/controllers/cart_controller.ex |
~p"/cart" → R.cart() |
lib/berrypod_web/controllers/contact_controller.ex |
~p"/contact" → R.contact() |
lib/berrypod_web/controllers/order_lookup_controller.ex |
~p"/orders/..." → R.order(num) |
lib/berrypod_web/controllers/seo_controller.ex |
Dynamic sitemap with current URLs |
lib/berrypod_web/page_renderer.ex |
Update links, page settings UI |
lib/berrypod_web/components/shop_components/layout.ex |
Update nav links to use R |
lib/berrypod_web/components/shop_components/product.ex |
~p"/products/..." → R.product(id) |
lib/berrypod_web/components/shop_components/cart.ex |
~p"/products/..." → R.product(id) |
lib/berrypod_web/components/shop_components/site_editor.ex |
Add URL prefix section |
lib/berrypod_web/live/shop/pages/*.ex |
Update OG URLs to R.url(R.product(id)) |
lib/berrypod_web/live/admin/orders/*.ex |
Update customer-facing order links |
test/berrypod_web/r_test.exs |
New test file |
test/berrypod/pages_test.exs |
URL change redirect tests |
test/berrypod/settings_test.exs |
Prefix change tests |
Verification
- Run test suite:
mix test— all existing tests pass - R module tests:
mix test test/berrypod_web/r_test.exs - Manual page slug test:
- Edit cart page, change URL to "basket"
- Visit
/basket— cart loads - Visit
/cart— 301 redirect to/basket - Complete checkout — cancel returns to
/basket
- Manual prefix test:
- Change products prefix to "p" in Site tab
- Visit
/p/any-product— product loads - Visit
/products/any-product— 301 redirect to/p/... - Check product links throughout site use
/p/
- SEO verification:
- Check
/sitemap.xmluses custom URLs - Check OG tags on custom URL pages
- Check canonical URLs match current paths
- Check
- Admin flow: Ensure
/admin/...routes unchanged
Estimate
| Phase | Time |
|---|---|
| 1. Data model | 1.5h |
| 2. Routes module | 2.5h |
| 3. Router refactor | 2.5h |
| 4. Update references | 4h |
| 5. Redirect integration | 1.5h |
| 6. Admin UI | 3h |
| 7. Testing | 2.5h |
| Total | 17.5h |
Risks
| Risk | Mitigation |
|---|---|
| Breaking Stripe checkout | Test checkout flow with custom cart/success URLs thoroughly |
| Cache invalidation bugs | Fallback to DB lookup on cache miss; comprehensive tests |
| Breaking existing bookmarks | Auto-redirects created for all URL changes |
| SEO impact | 301 redirects preserve SEO value; canonical URLs updated |
| Performance on prefix change | Bulk redirect creation could be slow with many products; consider Oban job |
| Conflicting slugs/prefixes | Validation prevents slug matching any prefix and vice versa |
| Rollback redirect complexity | URL versioning (draft-publish-workflow) handles redirect chain updates atomically |
Edge Cases
Collision prevention:
- Page slug cannot match any route prefix (e.g., can't rename
/aboutto/products) - Route prefix cannot match any page slug
- Custom page slugs validated against system page slugs
checkout_success path:
- Built as
page_path(:checkout_success)which returns the configured slug - Stripe success_url/cancel_url constructed dynamically at checkout time
- If renamed, old Stripe sessions still work (redirect catches them)
Admin routes unchanged:
- All
/admin/*routes stay hardcoded - Only shop-facing URLs are customisable
Interaction with Draft-Publish Workflow
This plan can be implemented independently, but integrates cleanly with draft-publish-workflow.md:
Without versioning (this plan alone):
- URL changes take effect immediately on save
- Redirect created from old URL to new URL
- No rollback capability for URLs
With versioning (both plans):
- URL is part of page version data (
page_versions.url_slug) - URL changes only take effect on publish
- Draft can have a different URL that's previewed but not live
- Rollback restores both content AND URL
- Redirects become a complete log of URL history
- All old URLs always redirect to current canonical
Implementation order options:
- URL customisation first — Simpler start, add versioning later
- Versioning first — URLs become versioned from the start
- Together — More complex but fully integrated from day one
Recommendation: Implement URL customisation first (this plan), then add versioning. The redirect integration code will need minor updates when versioning is added, but the R module and router changes are independent.