A production-ready headless CMS for managing blog content. Built with Vue 3 + Supabase. Designed to be used alongside a Next.js frontend that reads from the same Supabase database.
Built by axdigitalagency.com and atashaekh.com.
This is the admin/editor panel only — not the public-facing site. It lets you:
- Log in securely with email + password
- Create, edit, delete blog posts
- Upload cover images to Supabase Storage
- Set categories, tags, excerpt, slug
- Write rich content (headings, bold, links, lists, code blocks, etc.)
- Control publish/draft status
- Set SEO meta title and description with a live Google preview
The Next.js frontend (separate project) reads published posts from the same Supabase database using the public anon key.
| Layer | Tech |
|---|---|
| Framework | Vue 3 (Composition API, <script setup>) |
| Build tool | Vite |
| Language | TypeScript |
| State | Pinia |
| Routing | Vue Router |
| Database + Auth | Supabase (PostgreSQL) |
| Storage | Supabase Storage |
| Rich editor | Tiptap |
| Styling | CSS custom properties (dark editorial theme) |
src/
├── lib/
│ └── supabaseClient.ts # Supabase client init
├── stores/
│ └── auth.ts # Auth state (signIn, signUp, signOut)
├── router/
│ └── index.ts # Routes + auth guard
├── views/
│ ├── LoginView.vue # Login + signup page
│ ├── DashboardView.vue # Main dashboard (lists all posts)
│ └── BlogEditorView.vue # Create / edit post
├── components/
│ ├── BlogList.vue # Post list with category filter + CRUD actions
│ └── RichEditor.vue # Tiptap rich text editor with toolbar
└── App.vue # Shell: topbar + footer
Go to supabase.com → New project.
Run this in SQL Editor:
CREATE TABLE blogs (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
excerpt TEXT,
content TEXT NOT NULL,
cover_image_url TEXT,
category TEXT,
tags TEXT[] DEFAULT '{}',
meta_title TEXT,
meta_description TEXT,
author_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
author_name TEXT,
published BOOLEAN DEFAULT false,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes
CREATE UNIQUE INDEX blogs_slug_idx ON blogs(slug);
CREATE INDEX blogs_published_idx ON blogs(published, published_at DESC);
CREATE INDEX blogs_category_idx ON blogs(category);
CREATE INDEX blogs_tags_idx ON blogs USING GIN(tags);
CREATE INDEX blogs_fts_idx ON blogs
USING GIN(to_tsvector('english', title || ' ' || COALESCE(excerpt,'') || ' ' || content));
-- Auto-update updated_at
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN NEW.updated_at = NOW(); RETURN NEW; END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER blogs_updated_at
BEFORE UPDATE ON blogs
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Row Level Security
ALTER TABLE blogs ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Public read published" ON blogs FOR SELECT USING (published = true);
CREATE POLICY "Auth read all" ON blogs FOR SELECT TO authenticated USING (true);
CREATE POLICY "Auth insert" ON blogs FOR INSERT TO authenticated WITH CHECK (auth.uid() = author_id);
CREATE POLICY "Auth update own" ON blogs FOR UPDATE TO authenticated USING (auth.uid() = author_id);
CREATE POLICY "Auth delete own" ON blogs FOR DELETE TO authenticated USING (auth.uid() = author_id);- Go to Storage → New bucket
- Name:
blog-images - Toggle Public bucket ON → Create
Then run in SQL Editor:
CREATE POLICY "Auth upload" ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (bucket_id = 'blog-images');
CREATE POLICY "Public read" ON storage.objects
FOR SELECT USING (bucket_id = 'blog-images');
CREATE POLICY "Auth delete own" ON storage.objects
FOR DELETE TO authenticated
USING (bucket_id = 'blog-images');- Go to Authentication → Users → Add user
- Enter email + password → Create user
Or use the Sign Up form in the app (turn off email confirmation in Authentication → Providers → Email for development).
npm installCreate .env in the project root:
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-keyFind these in Supabase → Project Settings → API.
npm run devOpen http://localhost:5173 — you'll be redirected to /login.
npm run build| Field | Type | Description |
|---|---|---|
title |
Text | Post title |
slug |
Text (unique) | URL path — auto-generated from title, editable |
content |
HTML | Rich text body (from Tiptap editor) |
excerpt |
Text | Short summary shown in listings |
cover_image_url |
URL | Featured image — upload or paste URL |
category |
Text | Single category |
tags |
Text[] | Multiple tags (Enter or comma to add) |
author_name |
Text | Display name |
meta_title |
Text | SEO title (max 60 chars) |
meta_description |
Text | SEO description (max 160 chars) |
published |
Boolean | Draft vs published |
published_at |
Timestamp | Set automatically when published |
The Next.js site reads from the same Supabase project. Only published posts are visible to the public (RLS enforces this).
npm install @supabase/supabase-jsNEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-keyimport { createClient } from '@supabase/supabase-js'
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)const { data: posts } = await supabase
.from('blogs')
.select('id, title, slug, excerpt, cover_image_url, category, tags, published_at, author_name')
.eq('published', true)
.order('published_at', { ascending: false })const { data: post } = await supabase
.from('blogs')
.select('*')
.eq('slug', slug)
.eq('published', true)
.single()
// Render HTML content:
<div dangerouslySetInnerHTML={{ __html: post.content }} />const { data: posts } = await supabase
.from('blogs')
.select('id, title, slug, excerpt, cover_image_url, published_at')
.eq('published', true)
.eq('category', 'Technology')
.order('published_at', { ascending: false })export async function generateStaticParams() {
const { data } = await supabase
.from('blogs')
.select('slug')
.eq('published', true)
return data?.map(post => ({ slug: post.slug })) ?? []
}Supabase (PostgreSQL + Auth + Storage)
↑ write (CRUD) ↓ read (published only)
AX CMS — Vue 3 (this repo) Next.js public site
localhost:5173 localhost:3000
/login /blog
/ /blog/[slug]
/blog/new /blog/category/[cat]
/blog/:id/edit
- Create new Supabase project
- Run the
blogstable SQL - Create
blog-imagesstorage bucket + policies - Create admin user in Supabase Auth
- Update
.envwith new project URL and anon key - Update branding in
App.vue(brand-name) andLoginView.vue(form-eyebrow) - Run
npm install && npm run dev - Set up Next.js frontend with same Supabase credentials