Database Schema
Last updated on 2026-09-03
The kit uses 11 Supabase tables organized into four domains: content, engagement, marketing, and system. All tables have row-level security (RLS) enabled with a public-facing blog model -- public visitors can read published content, while admin operations are scoped by role.
Schema Overview
Content: profiles, categories, tags, posts, post_tags, media
Engagement: comments
Marketing: subscribers, newsletter_issues
System: site_settings, pageview_daily
Tables
profiles
Auto-created on user signup via database trigger. Stores blog author and admin information.
| Column | Type | Notes |
|---|---|---|
id |
uuid (PK, FK) | References auth.users.id |
full_name |
text | Display name |
email |
text | User email |
avatar_url |
text | Profile image URL |
bio |
text | Author biography |
role |
text | admin, editor, or author |
social_twitter |
text | Twitter/X handle |
social_github |
text | GitHub username |
social_linkedin |
text | LinkedIn profile URL |
social_website |
text | Personal website URL |
created_at |
timestamptz | Auto-set |
updated_at |
timestamptz | Auto-updated by trigger |
categories
Content categories for organizing posts.
| Column | Type | Notes |
|---|---|---|
id |
uuid (PK) | Auto-generated |
name |
text | Category name |
slug |
text (unique) | URL-safe identifier |
description |
text | Category description |
color |
text | Display color in oklch format |
image |
text | Category header image URL |
created_at |
timestamptz | Auto-set |
updated_at |
timestamptz | Auto-updated by trigger |
tags
Lightweight labels for cross-cutting post classification.
| Column | Type | Notes |
|---|---|---|
id |
uuid (PK) | Auto-generated |
name |
text (unique) | Tag label |
slug |
text (unique) | URL-safe identifier |
created_at |
timestamptz | Auto-set |
posts
The central content table. Supports draft, published, and scheduled statuses with full-text search.
| Column | Type | Notes |
|---|---|---|
id |
uuid (PK) | Auto-generated |
title |
text | Post title |
slug |
text (unique) | URL-safe identifier |
excerpt |
text | Short summary for listings |
content |
text | Full post body (HTML from rich text editor) |
featured_image |
text | Hero image URL |
category_id |
uuid (FK) | References categories.id |
author_id |
uuid (FK) | References profiles.id |
status |
text | draft, published, or scheduled |
published_at |
timestamptz | When the post went live |
scheduled_at |
timestamptz | Future publish date for scheduled posts |
reading_time |
integer | Estimated minutes to read (auto-calculated) |
views |
integer | View count (incremented via RPC) |
meta_title |
text | SEO title override |
meta_description |
text | SEO description override |
fts |
tsvector | Full-text search index on title, excerpt, and content |
created_at |
timestamptz | Auto-set |
updated_at |
timestamptz | Auto-updated by trigger |
post_tags
Junction table linking posts to tags.
| Column | Type | Notes |
|---|---|---|
post_id |
uuid (FK) | References posts.id |
tag_id |
uuid (FK) | References tags.id |
| Primary key | composite | (post_id, tag_id) |
comments
Threaded comments on posts. Public visitors can submit without authentication; comments start as pending.
| Column | Type | Notes |
|---|---|---|
id |
uuid (PK) | Auto-generated |
post_id |
uuid (FK) | References posts.id |
author_name |
text | Commenter's display name |
author_email |
text | Commenter's email (not displayed publicly) |
content |
text | Comment body |
status |
text | approved, pending, or spam |
parent_id |
uuid (FK, nullable) | References comments.id for threading |
created_at |
timestamptz | Auto-set |
media
Media library for images and files uploaded via Supabase Storage.
| Column | Type | Notes |
|---|---|---|
id |
uuid (PK) | Auto-generated |
filename |
text | Original file name |
url |
text | Public URL |
alt_text |
text | Accessibility alt text |
width |
integer | Image width in pixels |
height |
integer | Image height in pixels |
file_size |
integer | File size in bytes |
storage_path |
text | Path within Supabase Storage bucket |
uploaded_by |
uuid (FK) | References profiles.id |
created_at |
timestamptz | Auto-set |
subscribers
Newsletter subscriber list. Public visitors can subscribe without authentication.
| Column | Type | Notes |
|---|---|---|
id |
uuid (PK) | Auto-generated |
email |
text (unique) | Subscriber email address |
source |
text | Where they signed up (e.g., "homepage", "post", "newsletter") |
status |
text | active or unsubscribed |
created_at |
timestamptz | Auto-set |
newsletter_issues
Archive of sent newsletter issues.
| Column | Type | Notes |
|---|---|---|
id |
uuid (PK) | Auto-generated |
title |
text | Newsletter subject line |
description |
text | Newsletter body or summary |
sent_at |
timestamptz | When it was sent |
recipient_count |
integer | Number of recipients |
created_at |
timestamptz | Auto-set |
site_settings
Global blog configuration (single row).
| Column | Type | Notes |
|---|---|---|
id |
uuid (PK) | Auto-generated |
blog_name |
text | Blog title |
description |
text | Blog tagline/description |
logo_url |
text | Blog logo URL |
meta_title_template |
text | Default SEO title template (e.g., `%s |
meta_description_template |
text | Default SEO description template |
analytics_id |
text | Google Analytics measurement ID |
updated_at |
timestamptz | Auto-updated by trigger |
pageview_daily
Aggregated daily analytics. Each row represents one day of traffic.
| Column | Type | Notes |
|---|---|---|
date |
date (PK) | Calendar date |
views |
integer | Total page views for the day |
unique_visitors |
integer | Unique visitor count for the day |
Row-Level Security
RLS is enabled on every table. Unlike the CRM kit (which uses a team-only shared workspace model), this blog kit uses a public-facing model -- anonymous visitors can read published content, while write operations require authentication and are scoped by role.
Helper Functions
-- Check if the current user is a blog admin
CREATE OR REPLACE FUNCTION is_blog_admin()
RETURNS boolean AS $$
SELECT EXISTS (
SELECT 1 FROM public.profiles
WHERE id = auth.uid() AND role = 'admin'
);
$$ LANGUAGE sql SECURITY DEFINER;
-- Check if the current user is an editor or above (editor, admin)
CREATE OR REPLACE FUNCTION is_blog_editor_or_above()
RETURNS boolean AS $$
SELECT EXISTS (
SELECT 1 FROM public.profiles
WHERE id = auth.uid() AND role IN ('editor', 'admin')
);
$$ LANGUAGE sql SECURITY DEFINER;
-- Check if the current user is an author or above (author, editor, admin)
CREATE OR REPLACE FUNCTION is_blog_author_or_above()
RETURNS boolean AS $$
SELECT EXISTS (
SELECT 1 FROM public.profiles
WHERE id = auth.uid() AND role IN ('author', 'editor', 'admin')
);
$$ LANGUAGE sql SECURITY DEFINER;
Policy Summary
| Table | Public Read | Public Write | Authors+ | Editors+ | Admins |
|---|---|---|---|---|---|
| profiles | All profiles | - | Update own profile | - | Manage all profiles |
| categories | All | - | - | - | CRUD all |
| tags | All | - | Insert | - | Delete |
| posts | Published only | - | Insert, update own | CRUD all posts | Delete any |
| post_tags | All (via post) | - | Manage own post tags | Manage all | - |
| comments | Approved only | Insert (pending) | - | Approve, reject, delete | Delete any |
| media | - | - | Insert (upload) | CRUD all | Delete any |
| subscribers | - | Insert (signup) | - | Read, delete | Manage all |
| newsletter_issues | All | - | - | Insert, update | Delete |
| site_settings | All | - | - | - | Update |
| pageview_daily | - | - | - | Read | Read |
Key RLS Differences from CRM Kit
- Public read access -- profiles, categories, tags, published posts, approved comments, and newsletter issues are readable without authentication
- Public insert -- anonymous visitors can submit comments (created as
pending) and subscribe to the newsletter - Three-tier role model -- author, editor, admin (vs. CRM's two-tier member/admin)
- Content ownership -- authors can only edit their own posts, while editors can edit all posts
Triggers
Profile Auto-Creation
When a new user signs up, a trigger automatically creates their profile:
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS trigger AS $$
BEGIN
INSERT INTO public.profiles (id, full_name, avatar_url, email, role)
VALUES (
new.id,
coalesce(new.raw_user_meta_data ->> 'full_name', ''),
coalesce(new.raw_user_meta_data ->> 'avatar_url', ''),
new.email,
'author'
);
RETURN new;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
New users are assigned the author role by default.
Auto-Update updated_at
All tables with an updated_at column have a trigger that sets it to now() on every update.
Increment Post Views (RPC)
An RPC function safely increments a post's view count without requiring authentication:
CREATE OR REPLACE FUNCTION increment_post_views(post_slug text)
RETURNS void AS $$
UPDATE posts
SET views = views + 1
WHERE slug = post_slug AND status = 'published';
$$ LANGUAGE sql SECURITY DEFINER;
This is called from the public article page on load. Using SECURITY DEFINER allows the function to bypass RLS so anonymous visitors can trigger view increments.
Auto-Calculate Reading Time
When a post is inserted or its content is updated, a trigger calculates the estimated reading time:
CREATE OR REPLACE FUNCTION calculate_reading_time()
RETURNS trigger AS $$
BEGIN
NEW.reading_time := GREATEST(1, array_length(
regexp_split_to_array(strip_tags(NEW.content), '\s+'), 1
) / 200);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Assumes an average reading speed of 200 words per minute.
Seed Data
The supabase/seed.sql file includes:
| Entity | Count | Notes |
|---|---|---|
| Authors (profiles) | 5 | Including 1 admin: admin@example.com / password123 |
| Categories | 6 | Technology, Design, Business, etc. with oklch colors |
| Tags | 18 | React, TypeScript, CSS, Accessibility, etc. |
| Posts | 15 | Mix of published, draft, and scheduled; various categories and authors |
| Post-tag joins | ~40 | 2-4 tags per post |
| Comments | 20 | Mix of approved, pending, and spam; some threaded |
| Media items | 12 | Images with dimensions, alt text, and storage paths |
| Subscribers | 18 | Various sources and statuses |
| Newsletter issues | 5 | With sent dates and recipient counts |
| Site settings | 1 | Default blog configuration |
| Pageview daily | 30 | 30 days of traffic data with realistic variance |
Storage
The kit uses a public Supabase Storage bucket:
- Bucket name:
blog-assets - Access: Public read (images served directly via public URL)
- Upload policy: Authenticated users with
authorrole or above - Delete policy: Editors and admins only
Used for post featured images, media library uploads, author avatars, and category images.
Regenerating Types
After modifying the schema, regenerate TypeScript types:
npx supabase gen types typescript --project-id your-project-id > types/database.ts
This updates the typed Supabase client used throughout the application.