The boilerplate includes a powerful, multi-locale CMS for managing pages, blog posts (with categories, tags, and pagination), and media.
Content is stored in Supabase with JSONB columns for localized content, cached with Next.js
unstable_cache, and rendered with shortcode support.
Public block reads (getBlock and getBlocks), including the promo bar, use the shared Prisma runtime. They retain the 24-hour cache, invalidation tags and localized content fallback. Each batch accepts up to 100 keys through config/cms.ts; duplicate keys share one lookup and an empty batch performs no database work. PostgreSQL grants reads of only the five block columns and no direct table writes. Blocks are global public content, so they must never contain private account data or secrets.
A missing block returns no content. A database failure throws a closed error and is logged centrally, so an outage does not get cached as a missing promo bar. Admin block reads and mutations also use shared Prisma; other administrative data paths remain separate conversions.
Administrative block lists, pagination and ID lookups read the same five public block fields through shared Prisma without shared caching. Admin authorization remains on the existing pages and API. Lists retain a 200-block limit; pagination defaults to 10, allows at most 100 per page and bounds the offset at one million through config/cms.ts. Count and page rows share one snapshot, with newest-update sorting and stable ID ties. Missing IDs return no block; database failures are logged and thrown.
Block creation, updates and deletion use the same Prisma connection pool through dedicated SQL functions. Each mutation verifies the active application identity and platform-admin profile in the database; direct table writes remain forbidden to the application role. Content is sanitized and normalized to configured locales before writing. Renames and deletions return the actual previous key within the transaction, so cache invalidation after commit also covers concurrent edits. Missing blocks and duplicate keys have explicit outcomes; unexpected database failures remain generic and are logged centrally.
Single CMS page reads (getPage and getPageUncached) use shared Prisma with an indexed slug lookup and 16 explicit columns. PostgreSQL hides pages with no published locale; the repository applies the existing publication rule for the requested language, including explicit false and same-language fallback. Full localized maps remain available for SEO. Cached reads retain their 24-hour lifetime and invalidation tags; the uncached helper still applies public publication checks. Missing, invalid-slug or unpublished pages return no page, while database failures are logged and thrown.
Administrative page lists, pagination and ID/slug lookups use shared Prisma through private SQL functions that verify the active platform administrator. They include drafts without changing public publication restrictions. The 16 page fields and full locale maps remain available. Lists are limited to 200; pagination defaults to 10, allows at most 100 per page and caps the offset at one million through config/cms.ts. Count and rows share one read-only snapshot, ordered by update date and ID. These reads are uncached; missing pages return no result and database failures are logged and thrown.
Page creation, editing, publication and deletion also use shared Prisma with database checks of active administrator authority. Page changes and tag replacements share one transaction: omitting tag_ids preserves associations, while an empty array clears them. Unknown categories/tags and conflicting slugs cannot leave partial changes. Publication updates only the requested language under a row lock, preserving concurrent changes to other languages. Cache invalidation follows commit and uses the actual old/new slugs returned by the mutation.
Mutation limits live in config/cms.ts: 200 tags, a 2 MiB normalized database payload and a 2,048-character HTTP(S) featured-image URL. The API retains its 1 MiB request limit. Existing localized text limits and configured-locale normalization remain enforced. Missing pages return 404, conflicting slugs return 409 and invalid references return 400; unexpected failures are logged centrally with generic responses.
The public category and tag lists also use shared Prisma. The limits remain 100 categories and 200 tags, configured in config/cms.ts, with 24-hour caching and existing invalidation tags. Categories sort by display order and then ID; tags sort by slug. These read-only queries expose classification metadata. Database errors are logged and thrown instead of becoming cached empty lists.
Administrative category/tag lists, ID lookups and page tag IDs use uncached shared Prisma reads. Private SQL functions verify the active platform administrator before reading draft associations or usage counts. Category usage counts blog pages, including drafts; tag usage counts all page associations. PostgreSQL calculates these counts for the selected taxonomy rows in the same read snapshot, avoiding incomplete counts from fetching a limited list of associations.
Both administrative lists retain their 200-item limit through config/cms.ts and stable ordering. The editor accepts up to 200 tag IDs for one page and reports overflow instead of returning a partial selection. Missing records return no result; database failures are logged and thrown.
Category and tag creation, editing and deletion use shared Prisma with database checks of active administrator authority. Updates preserve omitted fields. Missing records return 404, duplicate slugs return 409 and invalid input returns 400. Deleting a category clears its page references; deleting a tag removes its associations and preserves pages. Cache invalidation runs after commit, including the CMS pages affected by category deletion. Unexpected failures return generic errors and are logged centrally.
Public blog pagination (getPaginatedBlogPosts) calls the existing invoker SQL function through shared Prisma. Publication and category/tag filters apply before counts and page limits, preserving exact-language false and same-language fallback. The hourly cache and nine-item default remain; page size is capped at 50 through config/cms.ts. Results contain summary fields, category and tags. Association grants expose only page_id and tag_id, with RLS hiding links for unpublished or non-blog pages. Database failures are logged and thrown.
Public blog details (getBlogPostWithMeta) also use shared Prisma. One read-only transaction reads a coherent snapshot of the page, category and tags using existing minimal grants. Publication is checked for the requested language before taxonomy reads, and full content/SEO maps remain intact. Tags sort by slug and use a maximum of 200 per article from config/cms.ts, with overflow detection. Admin create/update and tag replacement reject larger arrays before mutation. Oversized or malformed read results throw instead of returning partial articles. Missing, unpublished or invalid/non-blog slugs return no article. The hourly cache and invalidation tags are preserved.
CMS pages list with locale badges and actions
Page editor with WYSIWYG and SEO settings
Media library with drag-and-drop upload
File Structure
lib/cms/
├── queries.ts # CRUD operations & cached queries
├── storage.ts # Supabase Storage for media
├── types.ts # TypeScript interfaces
└── index.ts # Re-exports
lib/shortcodes/
├── parser.ts # Shortcode parsing engine
└── index.ts
components/
├── shortcodes/
│ └── index.tsx # Shortcode React components
├── html-renderer.tsx # Content renderer with shortcode support
└── admin/
└── media-library.tsx # Admin media library component
app/
├── [locale]/(admin)/admin-dashboard/cms/
│ ├── page.tsx # CMS overview
│ ├── pages/ # Pages management
│ │ ├── page.tsx # List pages
│ │ ├── new/ # Create page
│ │ └── [pageId]/ # Edit page
│ ├── blocks/ # Reusable content blocks
│ └── media/ # Media library
└── api/admin/cms/
├── pages/route.ts # Pages API
├── blocks/route.ts # Blocks API
└── media/route.ts # Media upload/delete APIPages & Blog
CMS pages and blog posts use the same data structure. Blog posts are simply pages with a
blog/ prefix in their slug. All content supports multi-locale with per-locale
publishing status.
Page Data Structure
Each CMS page stores its title, content, excerpt, and SEO metadata as JSONB fields keyed by locale (e.g., {"fr-FR": "...", "fr-CH": "...", "en-US": "...", "en-CA": "..."}). This means all translations for a page live in a single row, making queries efficient and avoiding join overhead.
The slug is shared across locales — it is a single column, not a per-locale map. One row is
served at /fr-FR/about and /en-US/about; you cannot give the French version a different
path such as /fr-FR/a-propos. Locale-prefixed URLs with a shared path are a fully supported search-engine
pattern, and it keeps the language switcher a simple prefix swap.
Per-locale publishing
published is a per-locale map, and it is evaluated strictly for the requested language.
A page published in French only is a 404 in English — it does not fall back to the French text. It is also
excluded from the English sitemap, omitted from the hreflang cluster, and shown as
“Not translated” in the language switcher.
The same strictness applies to Noindex, Nofollow, and Canonical URL: a value set on one language never leaks onto another. Display fields behave the opposite way on purpose — title, content, excerpt, SEO title/description and OG image do fall back, so a partially translated page still renders something rather than blank.
These slugs belong to dedicated routes and cannot be used for a CMS page — the built-in route always wins, so the CMS row would be unreachable: terms, privacy, privacy-choices, legal, pricing, contact, blog, changelog, affiliates. (The legal pages are pre-seeded CMS rows served through their own routes.) The list lives in lib/cms/seo.ts; add to it when you add a public route.
Database Schema
CMS pages are stored in the cms_pages table with columns for slug, status (published/draft), locale-keyed JSONB content, SEO metadata, and timestamps. CMS blocks use a similar structure in cms_blocks for reusable content snippets. Both tables have RLS policies restricting admin-only write access while allowing public read access for published content.
Fetching Content
CMS content is fetched through cached query functions in lib/cms/queries.ts. Pages are fetched by slug and cached for 24 hours using Next.js unstable_cache with tag-based revalidation. When content is updated through the admin CMS, the cache is automatically invalidated.
Rendering CMS Content
CMS pages are rendered through the dynamic route at app/[locale]/(frontend)/[slug]/page.tsx. The page component fetches the CMS content by slug, extracts the locale-specific fields (title, content, SEO metadata), and renders the HTML through the HtmlRenderer component. The renderer processes shortcodes, sanitizes the HTML, and applies consistent styling.
Blog Posts
Blog posts are CMS pages with a blog/ prefix in the slug:
| Slug | URL | Type |
|---|---|---|
about |
/[locale]/about | Standard page |
blog/getting-started |
/[locale]/blog/getting-started | Blog post |
blog/announcement |
/[locale]/blog/announcement | Blog post |
Blog Post Features
| Feature | Description |
|---|---|
| Featured Image | Header image with hover zoom on cards, full-width on detail page |
| Custom Excerpt | Per-locale excerpt (max 300 chars) with auto-generated fallback |
| Reading Time | Automatically calculated from content length |
| Publication Date | Shown on cards and detail page |
| Last Updated | Displayed if different from publication date |
| OpenGraph | Article metadata for social sharing (BlogPosting JSON-LD) |
| Categories | Color-coded categories with multi-locale names. One category per post. Filter bar on listing page (?category=slug) |
| Tags | Multi-locale tags (many per post). Displayed as badges on cards and detail page. Filter via ?tag=slug |
| Pagination | 9 posts per page with page numbers, prev/next navigation. URL-based (?page=2), preserves active filters |
Advanced SEO Controls
Each page has per-locale SEO settings:
| Field | Description |
|---|---|
| SEO Title | Custom meta title (overrides page title) |
| Meta Description | Custom description for search results |
| Noindex | Hide page from search engines (robots: noindex) |
| Nofollow | Prevent search engines following links (robots: nofollow) |
| Canonical URL | Custom canonical per locale (prevents duplicate content) |
| OG Image | Custom social share image (1200x630px recommended) |
Visual indicators in the admin show indexing status: green = indexed, amber = noindex.
The sitemap reads CMS and blog entries through shared Prisma. Its per-kind quota counts indexable rows after publication, noindex and canonical checks; ordered batches and a configured scan budget bound the work. Database or scan-budget failures raise an error instead of caching a partial inventory. See the SEO documentation for the limits.
Each of these is set independently per locale and never inherited from another language — setting a canonical
on the French version does not point the English version at the French URL. The sitemap honours them: a locale
flagged Noindex, or given a Canonical URL pointing at a different address, is
dropped from the sitemap and from the hreflang cluster instead of being advertised in a way that
contradicts the page’s own tags. See SEO & Sitemap.
Noindex hides a page from crawlers, not from readers. A locale marked noindex is still reachable and is still offered by the language switcher — only search engines are asked to skip it.
Pages API
| Endpoint | Method | Description |
|---|---|---|
/api/admin/cms/pages |
GET | List all pages (admin) |
/api/admin/cms/pages?id=xxx |
GET | Get page by ID |
/api/admin/cms/pages?slug=xxx |
GET | Get page by slug |
/api/admin/cms/pages |
POST | Create new page |
/api/admin/cms/pages |
PATCH | Update page (full edit) |
/api/admin/cms/pages |
PATCH | Toggle published per locale via { action: 'toggle_published', id, locale } |
/api/admin/cms/pages?id=xxx |
DELETE | Delete page |
Categories & Tags API
| Endpoint | Method | Description |
|---|---|---|
/api/admin/cms/categories |
GET | List all blog categories (multi-locale) |
/api/admin/cms/categories |
POST | Create category (multi-locale name + slug + color) |
/api/admin/cms/categories/[categoryId] |
PATCH | Update a category |
/api/admin/cms/categories/[categoryId] |
DELETE | Delete a category |
/api/admin/cms/tags |
GET | List all blog tags (multi-locale) |
/api/admin/cms/tags |
POST | Create tag |
/api/admin/cms/tags/[tagId] |
PATCH | Update a tag |
/api/admin/cms/tags/[tagId] |
DELETE | Delete a tag |
Every read of multi-locale JSONB fields (title, content, excerpt, published, seo_*) MUST go through the helpers in @/lib/i18n/localized: getLocalizedValue, getLocalizedStringValue, getLocalizedBooleanValue, and isLocalizedPublished. Older rows may still carry legacy language-only keys (fr, en) instead of full BCP-47 keys (fr-FR, en-US, en-CA, fr-CH); the helpers tolerate both forms. Reading JSONB with hardcoded keys (e.g. page.title['fr-FR'] or page.title.en) will silently render empty content for legacy rows. Admin write validators normalize incoming payloads back to BCP-47 so future inserts stay clean.
Cache Revalidation
On-demand cache revalidation is triggered when CMS content is updated through the admin dashboard. The system uses tag-based invalidation, so updating a specific page only clears that page's cache without affecting other cached content. CMS pages use 24-hour revalidation, billing data uses 1-hour revalidation, and home/pricing pages use ISR with 1-hour intervals.
Media Library
The Media Library uses a single Supabase Storage bucket (media, hardcoded in lib/cms/storage.ts and app/api/admin/cms/media/route.ts) with public reads and admin-only writes (gated by profiles.is_admin = true). Uploads are MIME-validated, capped at 10 MB, and filename-sanitized. The admin dashboard exposes a drag-and-drop UI at /admin-dashboard/cms/media with folder organization (uploads, images, documents, videos) and one-click URL copy.
See Media Library for the full API surface, bucket policy details, and security validation rules.
WYSIWYG Editor
The CMS includes a TipTap-based rich text editor with a comprehensive formatting toolbar.
Located in components/admin/wysiwyg-editor.tsx.
Formatting Features
| Category | Features |
|---|---|
| Text Formatting | Bold, italic, underline, strikethrough, inline code |
| Headings | H1, H2, H3 with keyboard shortcuts |
| Alignment | Left, center, right, justify |
| Lists | Bullet lists, ordered lists |
| Blocks | Blockquote, code block, horizontal rule |
| Media | Links with URL input, images via media picker |
| History | Undo/redo support |
Shortcode Insertion
The "Insert" dropdown menu provides quick insertion of shortcodes:
- YouTube/Vimeo video embeds
- Callout boxes (info, warning, success, error)
- CTA buttons with variants
- Accordions for collapsible content
- Cards with icons
- Highlighted text
- Dividers and spacers
Shortcodes
Shortcodes embed interactive React components inside CMS content using a WordPress-like syntax (e.g. [youtube id="abc"], [callout type="info"]…[/callout]). The registry lives in lib/shortcodes/parser.ts (SHORTCODE_DEFINITIONS) and the rendering switch in components/shortcodes/index.tsx (ShortcodeRenderer). Built-in shortcodes cover media embeds (YouTube, Vimeo), callouts, buttons, layout helpers (divider, spacer), and content blocks (accordion, card, highlight).
See Shortcodes for the complete attribute reference, syntax rules, and the steps to register a new shortcode.