cinqcinqdev-seo
v2.1.4
Published
A reusable Nuxt 3 admin CMS module with visual page editor powered by Supabase
Downloads
11,228
Readme
cinqcinqdev-seo
A Nuxt 3 admin CMS module with a visual page editor, AI content generation, SEO tooling, and multi-theme section components — powered by Supabase.
Your project checklist
Everything you need to do in the app that installs this package.
1 — Install
npm install cinqcinqdev-seo2 — nuxt.config
export default defineNuxtConfig({
modules: [
'@nuxtjs/supabase',
'@pinia/nuxt',
'@nuxt/icon',
'cinqcinqdev-seo',
],
adminCms: {
loginRoute: '/login', // where unauthenticated users are sent
branding: { name: 'My App' },
// Languages shown in the editor i18n switcher and quick-edit modal
// Default: ['fr', 'ar', 'en']
langs: ['fr', 'en'],
// Enable AI content generation and SEO audit
// Requires OPENROUTER_API_KEY in .env
features: { ai: true },
},
})3 — Environment variables
# Required for the built-in sitemap (/api/admin-cms/sitemap.xml)
NUXT_PUBLIC_SITE_URL=https://www.yoursite.com
# Required only if features.ai is true
OPENROUTER_API_KEY=sk-or-...4 — Supabase database
Run this SQL once in your Supabase SQL Editor to create all required tables:
-- Pages
CREATE TABLE IF NOT EXISTS public.pages (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
title text NOT NULL,
slug text NOT NULL UNIQUE,
type text NOT NULL DEFAULT 'landing_page',
seo_config jsonb DEFAULT '{
"meta_title": {"fr": "", "ar": "", "en": ""},
"meta_description": {"fr": "", "ar": "", "en": ""},
"og_image": "",
"no_index": false
}'::jsonb,
content jsonb DEFAULT '[]'::jsonb,
status text CHECK (status IN ('draft','published','archived')) DEFAULT 'draft',
content_locked boolean DEFAULT false,
published_at timestamptz,
page_css text DEFAULT '',
page_js text DEFAULT ''
);
ALTER TABLE public.pages ENABLE ROW LEVEL SECURITY;
CREATE POLICY "public_read_published" ON public.pages FOR SELECT USING (status = 'published');
CREATE POLICY "auth_full_access" ON public.pages FOR ALL TO authenticated USING (true) WITH CHECK (true);
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$ language 'plpgsql';
CREATE TRIGGER update_pages_modtime BEFORE UPDATE ON public.pages
FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column();
-- Brand settings (theme, colors, fonts, AI context)
CREATE TABLE IF NOT EXISTS public.brand_settings (
id INT PRIMARY KEY DEFAULT 1,
brand_name TEXT DEFAULT '',
primary_color TEXT DEFAULT '#000000',
secondary_color TEXT DEFAULT '#ffffff',
accent_color TEXT DEFAULT '#F0F0F3',
font_headline TEXT DEFAULT 'Inter',
font_body TEXT DEFAULT 'Inter',
ai_context TEXT DEFAULT '',
section_presets JSONB DEFAULT '{}'::jsonb,
theme TEXT CHECK (theme IN ('cinqcinq', 'swiss-style')),
shortcut_colors JSONB DEFAULT '[]'::jsonb,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT one_row_only CHECK (id = 1)
);
INSERT INTO public.brand_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
-- Users (plan management)
CREATE TABLE IF NOT EXISTS public.users (
id uuid PRIMARY KEY REFERENCES auth.users(id),
plan text DEFAULT 'free',
is_subscription_active boolean DEFAULT false,
trial_expires_at timestamptz,
created_at timestamptz DEFAULT now()
);
-- Page templates
CREATE TABLE IF NOT EXISTS public.pages_templates (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
created_at timestamptz DEFAULT now(),
name text NOT NULL,
content jsonb DEFAULT '[]'::jsonb,
source_page_id uuid REFERENCES public.pages(id) ON DELETE SET NULL
);
ALTER TABLE public.pages_templates ENABLE ROW LEVEL SECURITY;
CREATE POLICY "auth_full_access" ON public.pages_templates FOR ALL TO authenticated USING (true) WITH CHECK (true);If you already have a pages table and are upgrading, run only the missing columns:
ALTER TABLE public.pages ADD COLUMN IF NOT EXISTS published_at timestamptz;
ALTER TABLE public.pages ADD COLUMN IF NOT EXISTS page_css text DEFAULT '';
ALTER TABLE public.pages ADD COLUMN IF NOT EXISTS page_js text DEFAULT '';
ALTER TABLE public.brand_settings ADD COLUMN IF NOT EXISTS theme TEXT CHECK (theme IN ('cinqcinq', 'swiss-style'));
ALTER TABLE public.brand_settings ADD COLUMN IF NOT EXISTS shortcut_colors JSONB DEFAULT '[]'::jsonb;5 — Supabase Storage bucket
Create a bucket named site (or change storageBucket in your config):
-- In the Supabase SQL editor
CREATE POLICY "Public read" ON storage.objects FOR SELECT USING (bucket_id = 'site');
CREATE POLICY "Authenticated upload" ON storage.objects FOR INSERT TO authenticated WITH CHECK (bucket_id = 'site');
CREATE POLICY "Authenticated delete" ON storage.objects FOR DELETE TO authenticated USING (bucket_id = 'site');6 — Frontend page renderer (pages/[...slug].vue)
This file renders every CMS page on the public site. It must be a catch-all route (three dots) so subdirectory slugs like /faq/pricing work.
<!-- pages/[...slug].vue -->
<script setup>
const route = useRoute()
const supabase = useSupabaseClient()
const { settings, load } = useAdminSettings()
// Join path segments: ['faq', 'pricing'] → 'faq/pricing'
const slug = (route.params.slug).join('/')
const [{ data: page }] = await Promise.all([
useAsyncData(`page-${slug}`, async () => {
const { data } = await supabase
.from('pages')
.select('*')
.eq('slug', slug)
.eq('status', 'published')
.single()
return data
}),
load(),
])
if (!page.value) throw createError({ statusCode: 404, statusMessage: 'Page not found' })
// Inject page-specific CSS generated by the AI or written manually in the editor
if (page.value.page_css) {
useHead({ style: [{ innerHTML: page.value.page_css, id: `page-css-${slug}` }] })
}
// Inject page-specific JS (optional — only if you want JS to run on the public site)
if (page.value.page_js) {
useHead({ script: [{ innerHTML: page.value.page_js, id: `page-js-${slug}` }] })
}
useSeoMeta({
title: page.value.seo_config?.meta_title?.fr || page.value.title,
description: page.value.seo_config?.meta_description?.fr || '',
ogImage: page.value.seo_config?.og_image || '',
})
</script>
<template>
<AdminCmsDynamicRenderer
:content="page.content"
:selected-id="null"
:theme="settings?.theme"
:palette="settings?.shortcut_colors"
/>
</template>Multilingual: swap
?.frfor the active locale fromuseI18n()to serve the correct language. Scheduled publishing: add.lte('published_at', new Date().toISOString())to the Supabase query to hide future-dated pages.
6b — Rendering rich text (HTML) in your section components
The editor stores text field values as HTML when the user applies formatting (bold, italic, colors, font sizes). To render this correctly on the public site, your custom section components must use v-html instead of mustache {{ }} for text props.
Plain text (no formatting):
<h1>{{ title }}</h1>Rich text (HTML — supports bold, italic, colors, etc.):
<h1 v-html="title" />
<!-- or -->
<p v-html="subtitle" />If the prop may contain HTML or plain text, always use v-html:
<!-- Title with dynamic semantic tag and HTML rendering -->
<component :is="titleTag" v-html="title" class="..." />
<!-- Subtitle -->
<p v-if="subtitle" v-html="subtitle" class="..." />
<!-- List items (bullets, features) -->
<li v-for="(item, i) in parsedBullets" :key="i" v-html="item" />Security note:
v-htmlrenders raw HTML. Since prop values come from your own CMS editor (not from public user input), this is safe. Never usev-htmlwith untrusted external data.
The built-in cinqcinq and swiss-style section components already support plain text props via {{ }}. To enable HTML formatting in those components, override them in your components/sections/ directory using the same component name, and use v-html there.
7 — Setup page
Go to /admin/setup (or your configured basePath) and fill in:
- Theme — choose
cinqcinqorswiss-style - AI context — describe your website (industry, tone, services). Every AI call uses this as its system context.
- Primary / Secondary / Accent color — used by the AI CSS/JS generator to keep generated code on-brand
- Font Headline / Font Body — same — the AI uses these when generating CSS
- Shortcut colors — your quick-pick palette in every color picker
8 — Editor preview shell (navbar & footer)
By default the editor preview shows only the page sections. To make the preview look exactly like the real frontend — with your site's navbar on top and footer at the bottom — add previewShell to your config:
// nuxt.config.ts
adminCms: {
previewShell: {
navbar: 'AppNavbar', // the Vue component name Nuxt auto-imports your navbar as
footer: 'AppFooter', // the Vue component name Nuxt auto-imports your footer as
},
},Requirements:
- The components must be globally available — i.e. placed inside
~/components/so Nuxt auto-imports them. - Use the exact name Nuxt resolves them as (usually the filename in PascalCase, e.g.
components/AppNavbar.vue→'AppNavbar'). - Both keys are optional — omit
navbarto show only the footer, or omitfooterto show only the navbar. - The components receive no props — they render exactly as they would at the top/bottom of a real page. If your navbar or footer relies on a store or composable for its data, make sure that store is initialized in your app's
app.vueor a plugin.
Example:
| Your component file | previewShell name |
|---|---|
| components/AppNavbar.vue | 'AppNavbar' |
| components/layout/SiteFooter.vue | 'LayoutSiteFooter' |
| components/TheHeader.vue | 'TheHeader' |
What's New in v1.0.71
AI CSS and JS generation
A new AI panel appears in the SEO tab of the visual editor, above the Custom CSS and Custom JS editors.
How to use:
- Type a description of the design or behavior you want — e.g.:
- "Animated particles background using the primary color of the site"
- "Sticky header that shrinks and adds a shadow when the user scrolls down"
- "Smooth horizontal scroll section with overflow-x auto"
- Click one of three buttons:
</> CSS— generates CSS only⚡ JS— generates JavaScript onlyCSS + JS— generates both at once
- The generated code is written directly into the respective editor. Review it, tweak it if needed, then save.
Brand-aware: the AI automatically receives your site's primary color, secondary color, accent color, headline font, and body font from /admin/setup. Generated code uses these values — not arbitrary colors.
Merge, not replace: if there is already code in the editor, the AI adds to it rather than overwriting it (unless you explicitly ask it to replace everything).
Requires: features.ai: true in your config and OPENROUTER_API_KEY in your .env.
What's New in v1.0.65–v1.0.70
8 new section types — both themes
The section library grows from 20 to 28 built-in types. All 8 new sections ship in both cinqcinq and swiss-style:
| Type | Description |
|---|---|
| Stats | Animated counter grid — numbers count up when the section enters the viewport |
| Timeline | Vertical timeline with vertical (date column + dot line) and alternating layouts |
| Tabs | Content tabs with horizontal (border-bottom bar) and vertical (filled sidebar) layouts |
| VideoBlock | YouTube, Vimeo, or direct video file — embed auto-detected from URL |
| ImageGallery | Responsive image gallery with grid (1px gap) and masonry (CSS columns) layouts + lightbox |
| Spacer | Configurable vertical whitespace with an optional divider line |
| Embed | Generic iframe embed (maps, calendars, third-party widgets, etc.) |
| CustomHtml | Raw HTML block — inject any markup directly into the page |
Editor section picker — sections are now organised into four groups:
| Group | Sections | |---|---| | Content | Hero types, Text/Visual, Services, Features, Process, Testimonials, FAQ, Stats, Timeline, Tabs, RichText, TableOfContents | | Media | VideoBlock, ImageGallery | | Pricing & Info | Pricing, Comparison, DataTable, Quote, Newsletter, RelatedPages, BlogHero, ContactForm, CallToAction | | Utility | Spacer, Embed, CustomHtml |
Undo / Redo in the editor
The editor now has full undo/redo support:
- 50-step history — snapshots are taken 800 ms after the last change (debounced) so rapid edits are grouped
- Keyboard shortcuts —
Ctrl+Z/Cmd+Zto undo,Ctrl+Y/Ctrl+Shift+Z/Cmd+Shift+Zto redo - Toolbar buttons — undo and redo arrows in the top bar (disabled when the stack is empty)
- History is per-session and resets on page load
Per-breakpoint visibility
Every block now has a Visibility panel in the Properties sidebar (below Border). Toggle:
- Hide on Desktop — hides the section on screens ≥ 1024 px
- Hide on Tablet — hides the section on screens 768–1023 px
- Hide on Mobile — hides the section on screens < 768 px
Stored as hideOnDesktop, hideOnTablet, hideOnMobile boolean props on the block. DynamicRenderer applies the corresponding CSS classes automatically.
Shape Dividers
Every block has a Shape Dividers panel. Configure a decorative SVG divider at the top and/or bottom of any section:
- 5 shapes — Wave, Triangle, Curve, Slant, Zigzag
- Color — color picker for the divider fill
- Height — px height of the divider
Stored as dividerTop and dividerBottom objects on the block. Rendered by DynamicRenderer as absolutely-positioned SVGs overlapping adjacent sections.
Font Family in the Typography panel
The Typography panel in the Properties sidebar now includes a Font Family text input. Enter any Google Font name or CSS font stack (e.g. Inter, Playfair Display, Georgia, serif). Stored as fontFamily on the block and applied via inline style in the rendered section.
Block Meta (anchor, CSS class, note)
At the bottom of every block's Properties panel there is a Block Meta section with three fields:
| Field | Stored as | Purpose |
|---|---|---|
| Anchor ID | blockAnchor | Sets id on the section element for in-page # links |
| CSS Class | blockCssClass | Adds a custom class to the section wrapper |
| Internal Note | blockNote | Developer/editor note — never rendered on the front end |
Per-page CSS and JS (SEO tab)
The SEO tab in the editor now has two collapsible code editors at the bottom:
- Page CSS — CSS injected into
<style>in the document<head>while editing (live preview) and saved to thepage_csscolumn in Supabase - Page JS — JavaScript saved to the
page_jscolumn; load it from your frontend as needed
Required migration
ALTER TABLE public.pages
ADD COLUMN IF NOT EXISTS page_css text DEFAULT '';
ALTER TABLE public.pages
ADD COLUMN IF NOT EXISTS page_js text DEFAULT '';Quick-edit modal on the page list
Every row in the page list (/admin/pages/:type) now has a pencil icon that opens a modal to edit the page's metadata without going into the full editor.
What you can edit:
- Slug — the URL path
- Per-language fields — for each configured language (
langs), a titled card with:- Titre — maps to
seo_config.meta_title[lang](also sets the page'stitlecolumn from the primary language) - Description — maps to
seo_config.meta_description[lang]
- Titre — maps to
- Date de publication — sets
published_at
The modal does not close when clicking the backdrop — you must click "Annuler" or "Enregistrer" to dismiss it.
What's New in v1.0.61
Creation date + publish date in page lists
Every page list (/admin/pages/:type) now shows two date fields:
Creation date — displayed under the page slug in the "Page" column. Formatted as DD Mon YYYY using the fr-FR locale (e.g. 24 août 2026). Read directly from created_at.
Publication date picker — a new "Publication" column contains a <input type="date"> for each row. Selecting a date immediately saves it to the published_at column in Supabase and shows a confirmation toast. When a date is already set, the formatted date is displayed below the input in green. Clearing the input removes the date (sets published_at to null).
Required migration
Add the published_at column to your pages table if you are upgrading:
ALTER TABLE public.pages
ADD COLUMN IF NOT EXISTS published_at timestamptz;Your frontend can use this date to implement scheduled publishing — e.g. only render pages where published_at <= now():
const { data } = await supabase
.from('pages')
.select('*')
.eq('slug', slug)
.eq('status', 'published')
.lte('published_at', new Date().toISOString())
.single()What's New in v1.0.57
Per-page language selection + built-in sitemap
Language selector in the editor SEO panel — each page now has a "Langues générées" toggle section. Activate/deactivate individual languages (fr, ar, en or whatever langs are configured). The selection is stored in seo_config.langs and controls which locales are included in the sitemap. At least one language must stay active.
Built-in sitemap endpoint at GET /api/admin-cms/sitemap.xml:
- Reads all
publishedpages, skipsno_indexpages - Respects per-page language selection
- Generates
<url>blocks withhreflangalternates for multi-language sites - Single-language sites get plain
/{slug}URLs - Multi-language sites get
/{lang}/{slug}URLs (matches@nuxtjs/i18nprefix strategy) - Requires
NUXT_PUBLIC_SITE_URLorSITE_URLenv var for absolute<loc>values - Cached for 1 hour
See Per-page Language Selection & Sitemap for full usage.
What's New in v1.0.35
RichText editor — font size and list fixes
Two bugs in the rich text toolbar are fixed:
Font size not applying to selected text — when the user clicked the font size number input, focus left the contenteditable, clearing the browser's active selection. The saved range could become stale after Vue's reactive re-render. The fix tracks the active contenteditable element (
activeRteEl) and explicitly re-focuses it + restores the saved range before applying the size span.Bullet / numbered list not working on selected text —
insertUnorderedList/insertOrderedListsilently did nothing if the selection had disappeared (e.g. after a prior toolbar interaction).execRteCmdnow checks for an empty selection and restores it before executing the command.
Both fixes share a new restoreRteSelection() helper that re-focuses the correct contenteditable and re-adds the cloned range with a try/catch for stale-range errors.
What's New in v1.0.34
TableOfContents section — both themes
A new TableOfContents section is now available in both cinqcinq and swiss-style. Supports three layouts: sidebar (title left, items right), inline (centered header + stacked list), and grid (card grid). Items have label, anchor, and optional description fields. Clicking an item smooth-scrolls to the target #id on the page.
The section definition in useAdminSections is updated with the full set of standard props (spacing, radius, textSize, titleTag, marginTop, marginBottom, paddingX) and uses CSS variable color defaults so palette swatches work out of the box.
What's New in v1.0.24
Shortcut colors are now CSS variables
Each palette slot defined in /setup is injected as a CSS variable (--sc-0, --sc-1…) into the page head. When you click a swatch in the editor, the section stores var(--sc-0) — not the raw hex. Changing the color in /setup now instantly updates every section that used that swatch, with no per-block edits needed.
DynamicRendereraccepts a newpaletteprop — passsettings?.shortcut_colorsso CSS variables also resolve on the public-facing site<input type="color">still shows the visual hex (resolved from the palette) and overrides with a custom hex when used- Old plain hex values stored in section props continue to work as-is
Named palette slots
Shortcut colors now have a name (e.g. "Primary", "Accent") editable directly in /setup. The name is shown as a tooltip on every swatch in the editor.
What's New in v1.0.23
Configurable shortcut colors
The /setup page now has a Shortcut Colors section. Add and remove colors from your quick-pick palette — those swatches appear in every color picker in the editor (background, button, text, overlay, etc.). Defaults to #3d35ff / #ffffff / #000000 / #F9F9FB if nothing is configured. Saved to brand_settings.shortcut_colors.
AI context fix
The AI system prompt no longer hardcodes "web agency CMS". When an AI context is set in /setup, it is now used as the primary business context that drives every content generation and SEO audit call — not just appended after a generic persona.
Save without theme
The Save button in /setup no longer requires a theme to be selected. You can now save your AI context, shortcut colors, and component defaults independently of the theme choice.
Database: add shortcut_colors column
If you are upgrading, run this migration in Supabase:
ALTER TABLE public.brand_settings
ADD COLUMN IF NOT EXISTS shortcut_colors JSONB DEFAULT '[]'::jsonb;What's New in v2
Built-in section component library
All 19 section types now ship inside the module in two fully independent design themes. You no longer need to build section Vue files from scratch — just pick a theme on the Setup page and your sections render immediately.
theme prop on DynamicRenderer
AdminCmsDynamicRenderer now accepts a theme prop. Pass settings?.theme so your front-facing pages render the correct theme. See Using DynamicRenderer in front-facing pages.
Theme-aware component resolution
Both the visual editor and DynamicRenderer follow this priority when resolving a section:
- Your app's override —
~/components/sections/HeroSection.vue(registered asSectionsHeroSection) takes precedence over everything - Module theme —
sections/swiss-style/HeroSection.vue→SectionsSwissStyleHeroSection - Bare name fallback
This means you can override any individual section per project without losing the theme for the rest.
Theme selection on Setup
The Setup page has a visual theme picker. The selected theme is saved to brand_settings.theme.
Database: add theme column
If you are upgrading from v1, run this migration in Supabase:
ALTER TABLE public.brand_settings
ADD COLUMN IF NOT EXISTS theme TEXT CHECK (theme IN ('cinqcinq', 'swiss-style'));Features
- Visual 3-panel page editor (structure / live preview / properties)
- Multi-theme section library — choose between
cinqcinq(modern, rounded) andswiss-style(typographic, grid-based) - 28 built-in section types across 4 groups (Content, Media, Pricing & Info, Utility)
- Undo / Redo — 50-step history with keyboard shortcuts (
Ctrl+Z/Ctrl+Y) - Per-breakpoint visibility — hide any block on desktop, tablet, or mobile independently
- Shape Dividers — decorative SVG dividers at the top/bottom of any section
- Block Meta — anchor ID, custom CSS class, and internal note per block
- Per-page CSS & JS — inject custom styles and scripts saved to the database
- Quick-edit modal — edit slug, multilingual SEO title/description, and publish date from the page list without entering the editor
- AI content generation per section via OpenRouter (fr / ar / en simultaneously)
- AI SEO audit with multilingual meta title + description suggestions
- Project setup page: theme selection + AI context brief + configurable shortcut colors + per-component style defaults
- Multilingual content out of the box (
{ fr, ar, en }i18n objects) - Supabase-backed pages, brand settings, and image uploads
- Configurable
basePath— no conflict with existing/adminroutes - Pre-compiled Tailwind CSS scoped to
[data-admin-cms]— no Tailwind config needed in host app
Themes
The module ships two built-in design themes applied to all 19 section types. The active theme is selected once in the Setup page and stored in brand_settings.theme.
cinqcinq (default / original)
Modern style with rounded corners, card shadows, and smooth animations. Clean and accessible.
swiss-style
International Typographic Style. Strict 1px border grids, sharp rectangular buttons, tight uppercase typography (letter-spacing: -0.04em), ghost large numbers as structural markers, and subtle scroll-reveal fade animations (sv-in / sv-out). No rounded corners anywhere.
Theme resolution priority (per section render)
- Consuming app override —
~/components/sections/HeroSection.vue→ registered asSectionsHeroSection - Module theme —
sections/swiss-style/HeroSection.vue→SectionsSwissStyleHeroSection - Bare name fallback — component name as-is
Using DynamicRenderer in front-facing pages
Pass the active theme as a prop so sections render with the correct theme:
<script setup>
const { settings, load } = useAdminSettings()
await load()
</script>
<template>
<AdminCmsDynamicRenderer
:content="page.content"
:selected-id="null"
:theme="settings?.theme"
:palette="settings?.shortcut_colors"
/>
</template>The palette prop injects the shortcut colors as CSS variables (--sc-0, --sc-1…) into <head>, so any section whose color was set via a palette swatch renders with the correct color on the public site.
Requirements
- Nuxt 3
@nuxtjs/supabase@pinia/nuxt@nuxt/icon- An OpenRouter API key (optional — only needed if
features.ai: true)
Installation
See Your project checklist at the top of this document for the full step-by-step setup including database, environment variables, and the frontend page renderer.
npm install cinqcinqdev-seoDatabase Setup
See Your project checklist — step 4 for the full SQL. The checklist at the top of this document is the canonical reference and is always up to date.
Admin Routes
With default basePath: '/admin':
| Route | Description |
|---|---|
| /admin | Dashboard — page type cards with counts |
| /admin/setup | Project setup — AI context + component style defaults |
| /admin/pages/:type | Page list for a given type (create / delete / quick-edit) |
| /admin/editor/:id | Visual editor for a page |
| /admin/account | User profile + subscription plan |
Page List (/admin/pages/:type)
The page list shows all pages of a given type in a compact table grouped by slug directory. Columns:
| Column | Content |
|---|---|
| Page | Title, slug (monospace), creation date |
| SEO | "Optimisé" (green) when seo_config.meta_title is filled, "Incomplet" (amber) otherwise |
| Statut | Published (green badge) or Brouillon (grey badge) |
| Publication | Formatted publication date in green (published) or amber (scheduled draft); — when unset |
| Actions | Pencil (quick-edit), editor link, template, delete |
Quick-edit modal
Click the pencil icon on any row to open the quick-edit modal — a focused dialog for editing page metadata without entering the full editor.
Fields available:
- Slug — the URL path stored in the database
- Per-language cards — one card per configured language (
langs), each with:- Titre →
seo_config.meta_title[lang](the primary language value also sets the page'stitlecolumn) - Description →
seo_config.meta_description[lang]
- Titre →
- Date de publication — sets
published_at
The modal does not close on backdrop click. Use "Annuler" to discard or "Enregistrer" to save.
Page templates
Click the grid icon on any row to save the page's current content as a template. Give it a name — it will be available in the "Nouvelle page" modal when selecting "Template" as the starting point.
Configuration Reference
All options go under the adminCms key in nuxt.config.js.
| Option | Type | Default | Description |
|---|---|---|---|
| basePath | string | '/admin' | Base path for all admin routes |
| loginRoute | string | '/login' | Redirect unauthenticated users here |
| branding.name | string | 'Admin CMS' | Name shown in the sidebar |
| branding.logoUrl | string | '' | Logo URL shown in the sidebar |
| pageTypes | PageTypeConfig[] | 6 defaults | Page type cards on the dashboard |
| langs | string[] | ['fr', 'ar', 'en'] | Languages available in the editor i18n switcher and the quick-edit modal |
| plans | PlanConfig[] | [] | Subscription plans on the account page |
| tables.pages | string | 'pages' | Supabase table name for pages |
| tables.users | string | 'users' | Supabase table name for users |
| storageBucket | string | 'site' | Supabase storage bucket for image uploads |
| navSections | NavSectionItem[] | [] | Extra sidebar links |
| extraSections | Record<string, SectionConfig> | {} | Custom section types — appear in the editor picker and field panel automatically |
| features.ai | boolean | false | Enable AI content generation and SEO audit (requires OPENROUTER_API_KEY) |
Default page types
If pageTypes is empty, these are used:
[
{ id: 'landing_page', label: 'Landing Pages' },
{ id: 'service_page', label: 'Pages de Service' },
{ id: 'about_page', label: 'À Propos' },
{ id: 'product_page', label: 'Produits' },
{ id: 'blog_article', label: 'Articles' },
{ id: 'portfolio_item', label: 'Portfolio' },
]Override entirely:
adminCms: {
pageTypes: [
{ id: 'landing_page', label: 'Landing Pages', desc: 'Conversion pages' },
{ id: 'blog_article', label: 'Blog', desc: 'Articles & news' },
],
}Each entry accepts: id, label, icon (emoji), svgPath (SVG path string for sidebar icon), desc (shown on dashboard card).
Avoiding Route Conflicts
If your app already uses /admin, change the base path:
adminCms: {
basePath: '/cms',
}All routes, sidebar links, redirects, and the auth middleware automatically use /cms instead. Nothing in your app is touched.
Project Setup (/admin/setup)
The setup page is the first thing to fill in when starting a new project.
Theme selection
A visual picker lets you choose between cinqcinq and swiss-style. The choice is saved to brand_settings.theme and used by the editor and DynamicRenderer to resolve section components.
AI Context
A free-text field where you describe the website: industry, target audience, tone of voice, key services. Saved to brand_settings.ai_context.
This context becomes the primary business context for every AI call (content generation and SEO audit). The AI writes copy tailored to your actual project, not a generic "web agency" persona.
Shortcut Colors
A named color palette where each slot has a name (e.g. "Primary") and a hex value. Each slot is injected as a CSS variable (--sc-0, --sc-1, --sc-2…) into the page.
When you click a palette swatch in any editor color picker, the stored value is the CSS variable reference (var(--sc-0)) — not the raw hex. This means changing the color in /setup instantly updates every section that used that swatch, with no need to re-edit each block.
Saved to brand_settings.shortcut_colors. Defaults to Primary / White / Black / Light if nothing is configured.
Frontend pages: pass the palette to
DynamicRendererso CSS variables also resolve on the public-facing site (see Using DynamicRenderer).
Component Style Defaults
For each built-in section type you can set:
- Layout variant (e.g.
centered,split,bento,grid) - Animation (fade up, blur, reveal, typewriter, etc.)
- Border radius (sharp / rounded / pill)
- Spacing (compact / normal / spacious)
- Background and text colors
Saved to brand_settings.section_presets. When you add a section in the visual editor, these defaults are applied automatically so every new section matches the project's style from the start.
Visual Editor (/admin/editor/:id)
Three-panel layout:
Left — Structure
- List of all content blocks
- Reorder / delete blocks
- "Add section" button — opens a grouped modal (Content / Media / Pricing & Info / Utility)
- Toggle between Structure view and SEO view
Center — Live Preview
- Renders the actual section components
- Click any block to select it
- Language switcher (fr / ar / en) to preview each locale
Right — Properties
- Edit the selected block's fields (layout, colors, text, images, lists)
- Fields are grouped by tabs: Block (content fields), Style (spacing, colors, typography), SEO (page-level meta)
- AI button: type a prompt → generates all text fields in all three languages at once
- In SEO mode: AI SEO audit that scores the page and suggests optimised meta titles + descriptions
Undo / Redo
| Action | Keyboard shortcut |
|---|---|
| Undo | Ctrl+Z / Cmd+Z |
| Redo | Ctrl+Y / Ctrl+Shift+Z / Cmd+Shift+Z |
| Save | Ctrl+S / Cmd+S |
Snapshots are debounced (800 ms after the last change). Up to 50 steps are retained per session. Toolbar buttons in the top bar reflect the available state.
Properties panels (Block tab)
After the content fields for the selected block, the following panels are always present:
Typography — font size scale, font weight, text alignment, and font family (any Google Font name or CSS stack).
Visibility — hide the block on specific breakpoints without deleting it:
- Hide on Desktop (≥ 1024 px)
- Hide on Tablet (768–1023 px)
- Hide on Mobile (< 768 px)
Shape Dividers — add a decorative SVG divider at the top and/or bottom of the section. Choose from Wave, Triangle, Curve, Slant, or Zigzag. Set color and height independently for each edge.
Block Meta — internal metadata that does not affect the visual output:
- Anchor ID (
blockAnchor) — setsidon the section element for#hashlinks - CSS Class (
blockCssClass) — adds a custom class to the section wrapper - Note (
blockNote) — internal note for editors; never rendered
SEO tab — Page CSS and Page JS
Two collapsible code editors at the bottom of the SEO panel:
- Page CSS — injected live into
<style>in the editor preview and saved topage_cssin Supabase. Use it for page-specific overrides that don't belong in a global stylesheet. - Page JS — saved to
page_jsin Supabase. Load and execute from your frontend page as needed.
Required migration
ALTER TABLE public.pages
ADD COLUMN IF NOT EXISTS page_css text DEFAULT '';
ALTER TABLE public.pages
ADD COLUMN IF NOT EXISTS page_js text DEFAULT '';Content locking
If a page has content_locked = true in the database, the editor shows only the SEO panel. The page layout is treated as fixed (defined directly in your Vue page file) and cannot be edited via the CMS. Useful for hand-crafted pages like the homepage.
Section Components
The module ships 28 built-in section types in both themes. No setup beyond choosing a theme is required.
Content
| Type | Description |
|---|---|
| HeroSection | Hero with title, subtitle, badge, CTA, image |
| HeroSlider | Full-screen hero slider |
| HeroFan | Fan-layout hero |
| TextVisual | Text + image (split / stacked / wide) |
| ServiceGrid | Services in grid, list, cards, or minimal layout |
| Features | Feature highlights with icons |
| Process | Step-by-step process (steps / horizontal / timeline / cards) |
| Testimonials | Customer testimonials (grid / featured / wall) |
| FAQ | Accordion FAQ |
| Stats | Animated counter grid (counts up on scroll-reveal) |
| Timeline | Vertical or alternating timeline with dates and descriptions |
| Tabs | Content organised into horizontal or vertical tabs |
| RichText | Free rich-text block |
| TableOfContents | Anchor-linked table of contents (sidebar / inline / grid) |
Media
| Type | Description |
|---|---|
| VideoBlock | YouTube, Vimeo, or direct video — embed auto-detected from URL; supports autoplay, loop, muted, poster, and aspect ratio |
| ImageGallery | Responsive image gallery — grid (1px border gap) or masonry (CSS columns) layout — with a full-screen lightbox |
Pricing & Info
| Type | Description |
|---|---|
| Pricing | Pricing plans (cards / minimal / table) |
| Comparison | Side-by-side comparison table |
| DataTable | Generic data table |
| BlogHero | Blog article hero |
| Newsletter | Newsletter sign-up |
| Quote | Pull quote / blockquote |
| RelatedPages | Related pages / links grid |
| ContactForm | Contact form (split / centered / minimal) |
| CallToAction | CTA banner |
Utility
| Type | Description |
|---|---|
| Spacer | Configurable vertical whitespace with an optional horizontal rule |
| Embed | Generic <iframe> embed — maps, calendars, booking widgets, etc. |
| CustomHtml | Raw HTML block — inject any markup directly into the page |
Each section supports: layout variants, spacing, border radius, animation, background color, text color, per-breakpoint visibility, shape dividers, block anchor, and multilingual text fields.
Overriding a built-in section
You can override any individual section for a specific project by placing a Vue file in your app's components/sections/ directory. It takes priority over the module theme.
components/
sections/
HeroSection.vue ← overrides the theme version for this project onlyEach component receives its stored props directly via v-bind. Handle i18n fields (stored as { fr, ar, en } objects):
<!-- components/sections/HeroSection.vue -->
<script setup>
const props = defineProps(['title', 'subtitle', 'bgColor', 'textColor', 'ctaText', 'ctaLink', 'layout', 'animation'])
const { locale } = useI18n()
// Helper: resolve i18n field to a string
const t = (field) => {
if (!field || typeof field === 'string') return field || ''
return field[locale.value] || field.fr || Object.values(field).find(Boolean) || ''
}
</script>
<template>
<section :style="{ background: bgColor, color: textColor }">
<h1>{{ t(title) }}</h1>
<p>{{ t(subtitle) }}</p>
<NuxtLink v-if="ctaLink" :to="ctaLink">{{ t(ctaText) }}</NuxtLink>
</section>
</template>Adding custom section types
Two steps: register the schema in nuxt.config.js, then create the Vue component.
1 — Register the schema
// nuxt.config.js
adminCms: {
extraSections: {
StatsBar: {
label: 'Stats Bar',
icon: '📊',
// Which group it appears in inside the editor picker.
// Built-in groups: 'Hero', 'Content', 'Showcase', 'Pricing & Info', 'Conversion'
// Any other string creates a new group automatically.
group: 'Content',
defaultProps: {
items: [],
bgColor: '#0d0d0d',
textColor: '#ffffff',
},
fields: {
items: {
type: 'list',
label: 'Stats',
itemFields: {
value: { type: 'text', label: 'Number' },
label: { type: 'text', label: 'Label', i18n: true },
},
},
bgColor: { type: 'color', label: 'Background' },
textColor: { type: 'color', label: 'Text Color' },
},
},
},
}The key (StatsBar) becomes the section type stored in the database and matched to the component name.
2 — Create the Vue component
Place the file at components/sections/StatsBar.vue. Nuxt auto-imports it as SectionsStatsBar, which is exactly what DynamicRenderer looks for.
<!-- components/sections/StatsBar.vue -->
<template>
<section :style="{ backgroundColor: bgColor, color: textColor }" class="py-16 px-8">
<div class="flex flex-wrap gap-12 justify-center">
<div v-for="(item, i) in items" :key="i" class="text-center">
<p class="text-5xl font-black">{{ item.value }}</p>
<p class="text-sm mt-1 opacity-60">{{ t(item.label) }}</p>
</div>
</div>
</section>
</template>
<script setup>
const props = defineProps({
items: { type: Array, default: () => [] },
bgColor: { type: String, default: '#0d0d0d' },
textColor: { type: String, default: '#ffffff' },
})
// i18n helper — DynamicRenderer already unwraps {fr,ar,en} for you,
// but if you render outside DynamicRenderer, use this:
const t = (v) => {
if (!v || typeof v === 'string') return v || ''
return v[useNuxtApp().$i18n?.locale?.value] || v.fr || Object.values(v).find(Boolean) || ''
}
</script>How it works
DynamicRendererresolvesSections{Type}with this priority:- Your app's component —
components/sections/StatsBar.vue→SectionsStatsBar← this one - Module theme (e.g.
SectionsSwissStyleStatsBar) — only for built-in types - Bare component name fallback
- Your app's component —
- The editor reads the schema from
extraSections(merged viauseAdminSections()) to render the field panel and populate the "Add section" modal - Fields defined in
fieldsappear in the editor's Content and Style tabs automatically _style(CSS overrides) andcustomCssare added to every section automatically — no need to declare them
Custom group
If group is not one of the five built-in group names, a new group is created automatically at the bottom of the picker sidebar:
extraSections: {
MyInteractiveMap: {
label: 'Interactive Map',
group: 'My Components', // → new group appears in the picker
defaultProps: { ... },
fields: { ... },
},
}AI content generation
Text fields with i18n: true are automatically included in the AI "Generate" prompt — no extra configuration needed. Markdown fields (type 'markdown') are also filled by the AI in plain markdown syntax.
Field types
| Type | Description |
|---|---|
| text | Single-line input. Add i18n: true for fr/ar/en tabs |
| textarea | Multi-line input. Add i18n: true for fr/ar/en tabs |
| color | Color picker + hex input |
| image | Image URL input + Supabase storage upload button |
| select | Dropdown. Requires options: [{ value, label }] |
| cards | Visual card picker. Requires options: [{ value, label, icon? }] |
| list | Repeatable list. Requires itemFields: { fieldName: FieldConfig } |
Multilingual Content
Text fields with i18n: true are stored as:
{ "fr": "Titre français", "ar": "العنوان", "en": "English title" }Plain fields (colors, images, URLs, booleans) remain scalar values.
Fallback rule: current locale → fr → first non-empty value.
Per-page Language Selection & Sitemap
Choosing which languages to generate
Every page has a "Langues générées" section in the editor's SEO panel (right sidebar → SEO tab → scroll to bottom). Toggle each language on/off individually. At least one language must remain active.
- When all languages are active (default), the page behaves as before
- When a language is deactivated, it is excluded from the sitemap and your frontend can skip rendering that locale
The selection is stored in seo_config.langs as a string array:
{ "langs": ["fr", "en"] }Built-in sitemap endpoint
The module exposes a ready-made sitemap at:
GET /api/admin-cms/sitemap.xmlIt reads all published pages, respects no_index, applies the per-page language selection, and returns a valid XML sitemap with hreflang alternates.
URL format used by the sitemap:
| Site config | URL pattern |
|---|---|
| Single language (langs: ['fr']) | /{slug} |
| Multiple languages | /{lang}/{slug} (e.g. /fr/about, /en/about) |
This matches the
prefixstrategy of@nuxtjs/i18n. If you use a different strategy, generate your own sitemap from the/api/admin-cms/sitemap.xmldata or fetch pages directly from Supabase.
Required environment variable
The sitemap needs to know your public site URL to build absolute <loc> values:
# .env
NUXT_PUBLIC_SITE_URL=https://www.mysite.com
# or
SITE_URL=https://www.mysite.comUsing the sitemap in production
Option A — point your sitemap index directly at the endpoint:
<!-- /sitemap_index.xml -->
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>https://www.mysite.com/api/admin-cms/sitemap.xml</loc>
</sitemap>
</sitemapindex>Option B — proxy it to /sitemap.xml by adding a server route in your app:
// server/routes/sitemap.xml.ts
import { defineEventHandler, proxyRequest } from 'h3'
export default defineEventHandler((event) =>
proxyRequest(event, 'http://localhost:3000/api/admin-cms/sitemap.xml')
)Option C — use it alongside @nuxtjs/sitemap by registering it as a source:
// nuxt.config.ts
sitemap: {
sitemaps: {
pages: { urls: '/api/admin-cms/sitemap.xml' },
},
}Configuring available languages
The languages available in the per-page toggle come from your module config:
// nuxt.config.ts
adminCms: {
langs: ['fr', 'en'], // only FR and EN available in the editor
}Defaults to ['fr', 'ar', 'en'] when not specified.
AI Features
AI features are disabled by default. To enable them, set features.ai: true in your config and add OPENROUTER_API_KEY to your .env:
adminCms: {
features: { ai: true },
}OPENROUTER_API_KEY=sk-or-...Uses google/gemini-2.5-flash via OpenRouter.
Content generation
- Select a block in the editor
- Click the AI button (wand icon)
- Type a prompt — e.g. "Emphasise 10 years of expertise and affordable prices"
- All text fields (title, subtitle, items, etc.) are filled in fr + ar + en simultaneously
The AI always receives the AI Context from /admin/setup as a system prompt, ensuring output is always relevant to the specific project.
SEO audit
In SEO mode (left panel toggle), click "Analyser avec l'IA" to:
- Get a score (0–100)
- Receive an optimised meta title and meta description in fr / ar / en
- See a list of issues (errors, warnings, info)
- Get actionable suggestions
Apply the AI suggestions with one click, then adjust as needed.
Sidebar Extra Links
Add custom links to the sidebar's "Contenu" section:
adminCms: {
navSections: [
{
label: 'Messages',
to: '/admin/contact',
svgPath: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>',
},
],
}to can point to any route in your app — module pages or your own custom pages.
Subscription Plans (Account Page)
Show subscription plans on the /admin/account page:
adminCms: {
plans: [
{
value: 'starter',
title: 'Starter',
subtitle: 'For small projects',
price: '0 DZD',
features: ['5 pages', '1 user', 'Basic SEO'],
},
{
value: 'pro',
title: 'Pro',
subtitle: 'For growing agencies',
price: '4 900 DZD/mo',
features: ['Unlimited pages', 'AI generation', 'Priority support'],
popular: true,
},
],
}Plan state (plan, is_subscription_active, trial_expires_at) is read from the users table.
Auto-imported Composables
These are available everywhere in your app without importing:
useAdminSettings()
Load and save the brand_settings row (AI context, section presets, colors, fonts).
const { settings, loading, saving, load, save, applyPresets } = useAdminSettings()
await load()
// settings.value → { ai_context, section_presets, primary_color, ... }
await save({ ai_context: 'Web agency in Algiers...' })
// Apply saved style presets when adding a new section
const props = applyPresets('HeroSection', defaultProps)
// → merges layout, spacing, radius, animation, bgColor, textColor from brand_settingsuseAdminSections()
Returns the merged section config (built-ins + extraSections).
const sections = useAdminSections()
// { HeroSection: { label, icon, defaultProps, fields }, ... }useAdminBasePath()
Returns the configured admin base path string.
const basePath = useAdminBasePath()
// '/admin' or whatever is configureduseAdminBranding()
Load and save branding data from a branding table (separate from brand_settings).
const { fetchBranding, saveBranding } = useAdminBranding()
const data = await fetchBranding()
await saveBranding({ brand_name: 'My Agency', color_primary: '#3d35ff' })Styling
The module ships a pre-compiled Tailwind CSS file scoped to [data-admin-cms].
- Admin styles never leak into your app
- No Tailwind config required in the consuming project
- If your app uses
@nuxtjs/tailwindcss, the module automatically extends its content paths so admin classes are never purged - The section preview area in the editor inherits your app's own body font, so sections look exactly as they do on the live site
Auth
A global Nuxt route middleware is registered automatically:
- Redirects unauthenticated users from
basePath/*tologinRoute - Redirects authenticated users away from
loginRoutetobasePath
Auth state is read from useSupabaseUser() provided by @nuxtjs/supabase. Your login page and Supabase auth setup are your responsibility.
Rendering Pages on the Frontend
See Your project checklist — step 6 for the complete pages/[...slug].vue file including page_css/page_js injection, useSeoMeta, and AdminCmsDynamicRenderer usage.
Key points
- Use
pages/[...slug].vue(catch-all, three dots) — notpages/[slug].vue. A plain[slug].vueonly matches single-segment paths and will return 404 for subdirectory slugs like/faq/pricing. - Join the slug array:
(route.params.slug as string[]).join('/') - Pass
themeandpalettetoAdminCmsDynamicRendererso sections render with the correct design and palette swatch colors - Inject
page.page_cssviauseHeadif you want per-page CSS to apply on the public site - Inject
page.page_jsviauseHeadif you want per-page JS to run on the public site
Subdirectory slugs
When a page is created with subdirectory faq and title What is pricing, the slug stored in the database is faq/what-is-pricing. The slash is just part of the slug string — there is no nested table or parent record.
What's NOT Included
- Login / register pages — you provide these
- Email / webhook integrations
- Multi-tenancy / per-user page isolation — implement via Supabase RLS
Full Example
// nuxt.config.js
export default defineNuxtConfig({
modules: [
'@nuxtjs/supabase',
'@pinia/nuxt',
'@nuxt/icon',
'cinqcinqdev-seo',
],
supabase: {
redirect: false,
},
adminCms: {
basePath: '/admin', // change if /admin is already taken
loginRoute: '/login',
branding: {
name: 'My Agency',
logoUrl: '/logo.svg',
},
storageBucket: 'site',
tables: {
pages: 'pages',
users: 'users',
},
pageTypes: [
{ id: 'landing_page', label: 'Landing Pages', desc: 'Conversion pages' },
{ id: 'service_page', label: 'Services', desc: 'Service pages' },
{ id: 'blog_article', label: 'Blog', desc: 'Articles & news' },
],
navSections: [
{
label: 'Contacts',
to: '/admin/contact',
svgPath: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>',
},
],
plans: [
{
value: 'starter',
title: 'Starter',
price: '0 DZD',
features: ['5 pages', '1 user'],
},
{
value: 'pro',
title: 'Pro',
price: '4 900 DZD/mo',
features: ['Unlimited pages', 'AI generation', 'Priority support'],
popular: true,
},
],
extraSections: {
StatsBar: {
label: 'Stats Bar',
icon: '📊',
defaultProps: {
items: [],
bgColor: '#0d0d0d',
textColor: '#ffffff',
},
fields: {
items: {
type: 'list',
label: 'Stats',
itemFields: {
value: { type: 'text', label: 'Number' },
label: { type: 'text', label: 'Label', i18n: true },
},
},
bgColor: { type: 'color', label: 'Background' },
textColor: { type: 'color', label: 'Text Color' },
},
},
},
},
})