Build a headless blog backend in Lovable with posts, tags, auto-generated slugs, markdown content, and cover images in Supabase Storage. Public reads require no auth so any frontend can fetch posts. Authenticated writes protect your admin panel. Ship in about an hour.
What you're building
A headless blog backend separates writing from displaying. You manage posts through a Lovable admin dashboard, and your website or mobile app fetches content via a clean JSON API. This is ideal when you want to use your own frontend design (Next.js, Astro, React Native) but need a fast way to build the content management layer.
Supabase handles all persistence. The posts table stores title, markdown body, slug, and cover_image_url. Tags are stored in their own table with a post_tags junction for many-to-many relationships. A database trigger automatically generates the slug from the title when a new post is created.
Row-Level Security is configured so anon users can read published posts (enabling server-side rendering on your frontend without authentication), while only your authenticated admin session can write. The Edge Function wraps the Supabase query in a URL-friendly API with pagination and tag filtering.
Final result
A headless blog backend with a working admin UI, image uploads, auto-slugs, tags, and a public JSON API ready for any frontend.
Tech stack
Prerequisites
- Lovable Free account or higher
- Supabase project with URL and anon key saved to Cloud tab → Secrets
- A Supabase Storage bucket named 'blog-images' set to public
- Basic familiarity with markdown formatting
- Optional: a frontend (Next.js, Astro) that will consume the blog API
Build steps
Create the blog schema with auto-slug trigger
Prompt Lovable to create the posts and tags tables. The key detail is the slug trigger — it must generate a unique slug from the title automatically so you never have to think about slugs while writing.
1Create a headless blog with Supabase. Set up these tables:23- tags: id (uuid pk), name (text unique not null), slug (text unique not null), color (text, hex), created_at4- posts: id (uuid pk), title (text not null), slug (text unique not null), body (text, markdown), excerpt (text), cover_image_url (text), author_id (uuid references auth.users), status (text check in ('draft', 'published'), default 'draft'), published_at (timestamptz), read_time_minutes (int), created_at, updated_at5- post_tags: post_id (uuid references posts on delete cascade), tag_id (uuid references tags on delete cascade), PRIMARY KEY (post_id, tag_id)67RLS:8- posts: anon SELECT where status = 'published', authenticated SELECT all, authenticated INSERT/UPDATE/DELETE9- tags: anon SELECT, authenticated INSERT/UPDATE/DELETE10- post_tags: anon SELECT, authenticated INSERT/DELETE1112Create a trigger on posts BEFORE INSERT that:131. Generates slug from title: lowercase, non-alphanumeric to hyphens, trim hyphens142. If slug already exists, append -2, -3, etc. until unique1516Create a trigger BEFORE UPDATE that sets updated_at = now().1718Create a generated column: read_time_minutes GENERATED ALWAYS AS (greatest(1, round(array_length(regexp_split_to_array(trim(coalesce(body,'')), '\s+'), 1) / 200.0))) STORED.Pro tip: Ask Lovable to add a GIN full-text search index: ALTER TABLE posts ADD COLUMN search_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', coalesce(title,'') || ' ' || coalesce(body,''))) STORED; CREATE INDEX ON posts USING GIN(search_vector). This makes keyword search in the public API instant.
Expected result: All tables are created. Writing a title and saving a post automatically fills in the slug. TypeScript types are generated.
Build the post editor with image upload
Ask Lovable to create the post editing form. Cover image uploads go directly to Supabase Storage and the CDN URL is stored in the post row.
1Build a PostEditor component at src/components/PostEditor.tsx used for both new posts and editing existing ones. Accept props: post (Post | null), onSave callback.23Form fields (react-hook-form + zod):4- Title (Input required — slug preview appears beneath it in small text)5- Status (Select: Draft / Published)6- Tags (multi-select using a Command popover with Checkboxes for each tag, showing selected tags as Badges below)7- Excerpt (Textarea, max 250 chars, show char count)8- Body (Textarea, monospace, minimum height 400px, placeholder: 'Write in markdown...')9- Cover Image: file input (image/* only). On file select:10 1. Upload to Supabase Storage bucket 'blog-images' at path posts/{post.id || 'new'}/{filename}11 2. Get public URL12 3. Store in cover_image_url field13 4. Show a thumbnail preview1415On save:16- If status changes to 'published' and published_at is null, set published_at = new Date().toISOString()17- Upsert post18- Delete existing post_tags for this post, then insert new ones19- Show a toast: 'Post saved'2021Add a 'Preview' toggle that renders the body markdown in a div using a simple markdown parser.Expected result: The editor form opens in a Sheet. Entering a title shows the auto-generated slug. Image upload stores the file and shows a preview. Saving creates or updates the post and tags.
Build the admin posts DataTable
Create the main admin page showing all posts. Tag filter Badges at the top narrow the list. A search input filters by title.
1Build the main blog admin page at src/pages/Blog.tsx.23Layout:4- Page header 'Posts' with a 'New Post' Button that opens PostEditor in a Sheet5- Tag filter row: a horizontal list of Badge buttons, one per tag plus an 'All' Badge. Clicking a tag filters the DataTable.6- Search Input that filters by title client-side7- DataTable (TanStack Table) with columns:8 - Title (clickable text, opens editor Sheet)9 - Tags (row of small Badges)10 - Status (Badge: draft=gray, published=green)11 - Read Time (e.g. '3 min read')12 - Published At (date string or '—')13 - Actions (Edit Button, Delete AlertDialog)1415Fetch with tags: supabase.from('posts').select('*, post_tags(tags(id, name, color))')1617Delete confirmation AlertDialog: 'Are you sure? This cannot be undone.' On confirm, delete the post (post_tags will cascade delete due to the FK constraint).Expected result: The admin page shows all posts in a DataTable. Clicking a tag Badge filters to only that tag's posts. The search input narrows by title. Clicking a row opens the editor.
Create the public blog Edge Function
Build the Edge Function that powers your headless API. Any frontend calls this URL to get posts in JSON format — no Supabase client needed on the frontend.
1// supabase/functions/blog-api/index.ts2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'45const cors = {6 'Access-Control-Allow-Origin': '*',7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',8 'Content-Type': 'application/json',9}1011serve(async (req: Request) => {12 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })1314 const url = new URL(req.url)15 const path = url.pathname.replace('/functions/v1/blog-api', '')16 const supabase = createClient(17 Deno.env.get('SUPABASE_URL') ?? '',18 Deno.env.get('SUPABASE_ANON_KEY') ?? ''19 )2021 if (path === '/posts' || path === '/posts/') {22 const tag = url.searchParams.get('tag')23 const q = url.searchParams.get('q')24 const page = parseInt(url.searchParams.get('page') ?? '1')25 const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '10'), 50)26 const from = (page - 1) * limit2728 let query = supabase29 .from('posts')30 .select('id, title, slug, excerpt, cover_image_url, published_at, read_time_minutes, post_tags(tags(name, slug, color))', { count: 'exact' })31 .eq('status', 'published')32 .order('published_at', { ascending: false })33 .range(from, from + limit - 1)3435 if (tag) query = query.eq('post_tags.tags.slug', tag)36 if (q) query = query.textSearch('search_vector', q)3738 const { data, count, error } = await query39 if (error) return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: cors })40 return new Response(JSON.stringify({ data, meta: { page, limit, total: count } }), { headers: cors })41 }4243 const slugMatch = path.match(/^\/posts\/([\w-]+)$/)44 if (slugMatch) {45 const { data, error } = await supabase46 .from('posts')47 .select('*, post_tags(tags(*))')48 .eq('slug', slugMatch[1])49 .eq('status', 'published')50 .single()51 if (error || !data) return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })52 return new Response(JSON.stringify({ data }), { headers: cors })53 }5455 if (path === '/tags') {56 const { data, error } = await supabase.from('tags').select('*').order('name')57 if (error) return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: cors })58 return new Response(JSON.stringify({ data }), { headers: cors })59 }6061 return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })62})Expected result: The Edge Function deploys. GET /functions/v1/blog-api/posts returns a paginated list of published posts. GET /posts/{slug} returns a single post with tags.
Complete code
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'34const cors = {5 'Access-Control-Allow-Origin': '*',6 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',7 'Content-Type': 'application/json',8}910serve(async (req: Request) => {11 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })1213 const url = new URL(req.url)14 const path = url.pathname.replace('/functions/v1/blog-api', '')15 const supabase = createClient(16 Deno.env.get('SUPABASE_URL') ?? '',17 Deno.env.get('SUPABASE_ANON_KEY') ?? ''18 )1920 if (path === '/posts' || path === '/posts/') {21 const tag = url.searchParams.get('tag')22 const q = url.searchParams.get('q')23 const page = parseInt(url.searchParams.get('page') ?? '1')24 const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '10'), 50)25 const from = (page - 1) * limit2627 let query = supabase28 .from('posts')29 .select('id, title, slug, excerpt, cover_image_url, published_at, read_time_minutes, post_tags(tags(name, slug, color))', { count: 'exact' })30 .eq('status', 'published')31 .order('published_at', { ascending: false })32 .range(from, from + limit - 1)3334 if (tag) query = query.eq('post_tags.tags.slug', tag)35 if (q) query = query.textSearch('search_vector', q)3637 const { data, count, error } = await query38 if (error) return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: cors })39 return new Response(JSON.stringify({ data, meta: { page, limit, total: count } }), { headers: cors })40 }4142 const slugMatch = path.match(/^\/posts\/([\w-]+)$/)43 if (slugMatch) {44 const { data, error } = await supabase45 .from('posts')46 .select('*, post_tags(tags(*))')47 .eq('slug', slugMatch[1])48 .eq('status', 'published')49 .single()50 if (error || !data) return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })51 return new Response(JSON.stringify({ data }), { headers: cors })52 }5354 if (path === '/tags') {55 const { data, error } = await supabase.from('tags').select('*').order('name')56 if (error) return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: cors })57 return new Response(JSON.stringify({ data }), { headers: cors })58 }5960 return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })61})Customization ideas
Series and chapter ordering
Add a series table and a series_id column to posts with a chapter_number int. The API accepts a ?series=slug parameter and returns posts in chapter order. The admin editor adds Series and Chapter Number fields. Readers see 'Part 3 of 5 in the Getting Started series'.
Comment section
Add a comments table (post_id, author_name, author_email, body, is_approved, created_at). The public API returns approved comments with each post. The admin page shows a comments moderation queue with approve and reject actions.
RSS feed endpoint
Add a /feed route to the Edge Function that returns a valid RSS 2.0 XML document of the latest 20 published posts. Set the Content-Type header to application/rss+xml. This makes your blog subscribable in Feedly, Reeder, and other RSS readers.
Newsletter integration
When a post is published, fire a webhook to your newsletter system (built using the newsletter-subscriptions guide) with the post title, excerpt, and URL. Your newsletter Edge Function then queues a broadcast email to all active subscribers.
Common pitfalls
Pitfall: Forgetting to set published_at when changing status to published
How to avoid: In the editor save handler, check: if (status === 'published' && !post.published_at) then set published_at = new Date().toISOString() before the upsert call.
Pitfall: Not cascading deletes from post_tags when a post is deleted
How to avoid: The post_tags table should have post_id references posts ON DELETE CASCADE. If it does not, ask Lovable to add this in a migration: ALTER TABLE post_tags ADD CONSTRAINT post_tags_post_id_fkey FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE.
Pitfall: Using the anon key in the Edge Function to write data
How to avoid: Use the service role key (SUPABASE_SERVICE_ROLE_KEY) in Edge Functions when you need to bypass RLS for admin operations. Keep the anon key only for public read queries.
Pitfall: Forgetting to add an index on the slug column causing slow page lookups
How to avoid: Add a unique index on the slug column: CREATE UNIQUE INDEX ON posts(slug). The UNIQUE constraint in the schema definition creates this index automatically, but verify it exists in Supabase Dashboard → Database → Indexes. The unique constraint also prevents duplicate slugs from ever being inserted.
Best practices
- Store markdown in the database, not HTML. Render to HTML at display time. This keeps your content portable and allows re-rendering with different styles.
- Auto-generate slugs in a database trigger, not in the frontend. The trigger guarantees uniqueness and consistent formatting even if multiple admins create posts simultaneously.
- Configure Supabase Storage for the blog-images bucket with a max file size limit (e.g. 5MB) and allowed MIME types (image/jpeg, image/png, image/webp) to prevent oversized uploads.
- Use the generated read_time_minutes column rather than calculating it in the frontend. Database-computed values are consistent across all consumers of your API.
- Add a LIMIT on the public API endpoint (max 50 items per request) to prevent expensive queries. Always paginate — never return all posts in a single response.
- Test your RLS policies by querying the posts table from a browser DevTools console using the anon key. If you can read draft posts as anon, your RLS is misconfigured.
AI prompts to try
Copy these prompts to build this project faster.
I have a Supabase blog with posts (title, body, slug, status, published_at) and post_tags junction table. Show me a Next.js 14 App Router server component that renders a blog index page with pagination (10 posts per page) and tag filtering. Use the Supabase JS client with the service role key on the server side. Include generateStaticParams for the first 3 pages and revalidate every 60 seconds using Next.js ISR.
Add a post duplication feature to the admin DataTable. Clicking 'Duplicate' on a post row creates a new post with the same title (appended with ' - Copy'), same body and tags, but status set to 'draft' and a new auto-generated slug. Show a toast with a link to edit the duplicated post. No confirmation dialog needed.
In Supabase, write a SQL function get_related_posts(post_uuid uuid, max_count int) that returns posts sharing the most tags with the given post, excluding the post itself, limited to max_count results. The function should return id, title, slug, cover_image_url, and published_at. I will call this from my blog-api Edge Function when serving a single post to include a related posts section.
Frequently asked questions
How is this different from the Simple CMS guide?
The Blog Backend is the simpler starter: posts, tags, auto-slugs, and a public API — no categories, no image library, no roles. The Simple CMS adds categories, draft/published/archived workflow, a media library, category management with post-count tracking, and more admin features. Start with the Blog Backend and upgrade to the CMS when you need those features.
Can I use this with a Next.js frontend for SEO?
Yes, and this is the recommended setup. Fetch posts on the server side in a Next.js server component using the Supabase JS client or the Edge Function URL. Next.js renders the HTML on the server, so search engines receive fully-rendered content. Use ISR (revalidate: 60) to regenerate pages after content updates without a full redeploy.
How does the auto-slug trigger handle duplicate titles?
The trigger generates the slug from the title, then checks if it already exists in the posts table. If it does, it appends -2, -3, and so on until it finds a unique slug. This means two posts titled 'Getting Started' get slugs 'getting-started' and 'getting-started-2' automatically.
What is the maximum size for cover images?
Supabase Storage allows up to 50MB per file on the free plan. For blog cover images, 2-5MB is a practical limit for web use. Add a file size check in the PostEditor before uploading: if (file.size > 5 * 1024 * 1024) { showToast('Image must be under 5MB'); return; }. This prevents unnecessarily large files without requiring backend configuration.
Do I need auth set up for the admin to work?
Yes. The admin panel (PostEditor, DataTable) uses Supabase Auth to identify the current user. Without an authenticated session, the INSERT and UPDATE calls are blocked by RLS. Set up a simple email/password login page in Lovable by asking: 'Add a login page at /login using Supabase Auth with email and password. Redirect to /blog after login. Protect the /blog route to require authentication.'
How do I delete a tag that is used by posts?
The post_tags table has ON DELETE CASCADE on the tag_id foreign key. Deleting a tag automatically removes all its associations from post_tags. The posts themselves are unaffected — they just lose that tag. Add a warning in the tag delete confirmation: 'This tag is used by X posts. Deleting it will remove it from all posts.' Show the post count before confirming.
Can I schedule posts to publish at a future date?
Not in this basic setup. Add a publish_scheduled_at column to posts. Create a Supabase scheduled function (via pg_cron or a cron-triggered Edge Function) that runs every 5 minutes and updates status to 'published' for posts where publish_scheduled_at <= now() and status = 'draft'. The PostEditor gets a DateTimePicker for the scheduled date.
Can I get help connecting this to my existing site?
RapidDev integrates Lovable-built blog backends with any frontend stack including Next.js, Astro, and SvelteKit. Reach out if you need help wiring the API to your site.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation