npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 /admin routes
  • 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-seo

Add 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

  1. Select a block in the editor
  2. Click the AI button (wand icon)
  3. Type a prompt — e.g. "Emphasise 10 years of expertise and affordable prices"
  4. 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_settings

useAdminSections()

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 configured

useAdminBranding()

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/* to loginRoute
  • Redirects authenticated users away from loginRoute to basePath

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 to pages/[...slug].vue and 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' },
        },
      },
    },
  },
})