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.
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.
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.
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.