cinqcinqdev-seo
v0.1.60
Published
A reusable Nuxt 3 admin CMS module with visual page editor powered by Supabase
Readme
cinqcinqdev-seo
A Nuxt 3 admin CMS module with a visual page editor, AI content generation, and SEO tooling — powered by Supabase.
Features
- Visual 3-panel page editor (structure / live preview / properties)
- AI content generation per section via OpenRouter (fr / ar / en simultaneously)
- AI SEO audit with multilingual meta title + description suggestions
- Project setup page: AI context brief + 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
Requirements
- Nuxt 3
@nuxtjs/supabase@pinia/nuxt@nuxt/icon- An OpenRouter API key (optional — only needed if
features.ai: true)
Installation
pnpm add cinqcinqdev-seoAdd to nuxt.config.js:
export default defineNuxtConfig({
modules: [
'@nuxtjs/supabase',
'@pinia/nuxt',
'@nuxt/icon',
'cinqcinqdev-seo',
],
adminCms: {
branding: { name: 'My App' },
loginRoute: '/login',
},
})Database Setup
Run this SQL in your Supabase project (SQL Editor):
-- Pages table
CREATE TYPE page_type AS ENUM (
'landing_page', 'service_page', 'about_page',
'product_page', 'blog_article', 'portfolio_item'
);
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 page_type 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
);
ALTER TABLE public.pages ENABLE ROW LEVEL SECURITY;
-- Public can read published pages
CREATE POLICY "public_read_published" ON public.pages
FOR SELECT USING (status = 'published');
-- Authenticated users have full access
CREATE POLICY "auth_full_access" ON public.pages
FOR ALL TO authenticated USING (true) WITH CHECK (true);
-- Auto-update updated_at
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();
-- Users table (for 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()
);
-- Brand settings (AI context, component presets, colors, fonts)
CREATE TABLE IF NOT EXISTS public.brand_settings (
id INT PRIMARY KEY DEFAULT 1,
brand_name TEXT DEFAULT '',
tagline TEXT,
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,
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;
-- Page templates (saved from the page list via "Turn into template")
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);Storage bucket
Create a bucket named site (or your storageBucket value) in Supabase Storage. Add these policies:
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');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) |
| /admin/editor/:id | Visual editor for a page |
| /admin/account | User profile + subscription plan |
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 |
| 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> | {} | Extra section types for the visual editor |
| 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.
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 is automatically prepended to every AI call (content generation and SEO audit), so the AI always writes copy relevant to the actual project — not generic placeholder text.
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 modal with all available types
- 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 (Design, Animation, etc.)
- 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
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 these built-in section types:
| Type | Description |
|---|---|
| HeroSection | Hero with title, subtitle, badge, CTA, image |
| 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) |
| Pricing | Pricing plans (cards / minimal / table) |
| FAQ | Accordion FAQ |
| ContactForm | Contact form (split / centered / minimal) |
Each section supports: layout variants, spacing, border radius, animation, background color, text color, and multilingual text fields.
Creating section components
The editor resolves section components from your app's components/sections/ directory.
components/
sections/
HeroSection.vue
TextVisual.vue
ServiceGrid.vue
...Each 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
Define the section config in nuxt.config.js:
adminCms: {
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' },
},
},
},
}Create components/sections/StatsBar.vue in your app. The section will appear in the editor's "Add section" modal automatically.
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.
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.0-flash-lite-001 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
Pages created in the CMS must be rendered by your Nuxt app.
Required route file: pages/[...slug].vue
Slugs can contain slashes (e.g. faq/my-question when a subdirectory is set). A standard [slug].vue file only matches a single path segment — it will never match /faq/my-question and will return 404.
You must use a catch-all route:
| File | Matches |
|---|---|
| pages/[...slug].vue ✅ | /about, /faq/pricing, /services/web-design |
| pages/[slug].vue ❌ | /about only — /faq/pricing returns 404 |
If you already have
pages/[slug].vue, rename it topages/[...slug].vueand update the slug reference as shown below. Both flat slugs and subdirectory slugs will continue to work.
pages/[...slug].vue
<script setup>
const route = useRoute()
const supabase = useSupabaseClient()
// route.params.slug is an array of path segments — join them back into the DB slug
// e.g. /faq/pricing → ['faq', 'pricing'] → 'faq/pricing'
// e.g. /about → ['about'] → 'about'
const slug = (route.params.slug as string[]).join('/')
const { data: page } = await useAsyncData(`page-${slug}`, async () => {
const { data } = await supabase
.from('pages')
.select('*')
.eq('slug', slug)
.eq('status', 'published')
.single()
return data
})
if (!page.value) throw createError({ statusCode: 404, statusMessage: 'Page not found' })
useSeoMeta({
title: page.value.seo_config?.meta_title?.fr || page.value.title,
description: page.value.seo_config?.meta_description?.fr || '',
})
</script>
<template>
<div>
<component
v-for="section in page.content"
:key="section.id"
:is="`sections-${section.type}`"
v-bind="section.props"
/>
</div>
</template>How subdirectory slugs are stored
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 frontend URL is /faq/what-is-pricing. There is no nested table or parent record — the slash is just part of the slug string.
Pages without a subdirectory (e.g. title About, no subdirectory) get a plain slug like about and are served at /about — exactly as before.
What's NOT Included
- Login / register pages — you provide these
- Section component UI — you build the Vue components in
components/sections/ - 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' },
},
},
},
},
})