Build a headless CMS in Lovable with posts, categories, and markdown body fields backed by Supabase. You get a drag-and-drop admin DataTable, draft/published workflow with Tabs, image uploads to Supabase Storage, and a public JSON API Edge Function — all in about 2 hours.
What you're building
A headless CMS separates content management from content display. You manage posts through an admin dashboard, and your frontend (or any other system) fetches content via a JSON API. This is exactly how large publishing platforms work at scale.
Supabase is the perfect backend for this pattern. The posts table stores all content including a markdown body field. Supabase Storage holds featured images and returns permanent CDN URLs. Row-Level Security allows anonymous reads on published posts while restricting writes to authenticated admin users.
The admin panel built in Lovable gives you a DataTable of all posts with sortable columns. Tabs split the view by status: All, Draft, and Published. Clicking a row opens an editor dialog with a markdown Textarea, category Select, status Switch, and featured image uploader. A Supabase Edge Function wraps the posts query in a clean REST endpoint that any headless frontend can consume.
Final result
A fully functional headless CMS with an admin UI, image storage, status workflow, and a public API — ready to power any frontend.
Tech stack
Prerequisites
- Lovable Pro account (Edge Function generation requires credits)
- Supabase project created with URL and anon key saved to Cloud tab → Secrets
- A Supabase Storage bucket named 'cms-images' set to public
- Basic understanding of markdown formatting
- Optional: a frontend project (Next.js, Astro, etc.) that will consume the CMS API
Build steps
Create the CMS schema in Supabase
Prompt Lovable to generate the posts and categories tables. The schema covers all standard CMS fields including auto-slug generation from the title, status enum, and a tsvector column for future full-text search.
1Create a headless CMS with Supabase. Set up these tables:23- categories: id (uuid pk), name (text unique not null), slug (text unique not null), description (text), color (text, hex color for UI badges), post_count (int default 0), created_at4- posts: id (uuid pk), title (text not null), slug (text unique not null), body (text, markdown), excerpt (text, max 300 chars), featured_image_url (text), author_id (uuid references auth.users), category_id (uuid references categories), status (text check in ('draft', 'published', 'archived'), default 'draft'), published_at (timestamptz, set when status becomes 'published'), meta_title (text), meta_description (text), created_at, updated_at56RLS:7- posts SELECT: allow anon and authenticated for status = 'published'8- posts INSERT/UPDATE/DELETE: allow authenticated users only9- categories SELECT: allow anon and authenticated10- categories INSERT/UPDATE/DELETE: allow authenticated only1112Create a trigger on posts that auto-generates slug from title (lowercase, spaces to hyphens, remove special chars) if slug is empty on INSERT.13Create a trigger that sets updated_at = now() on every UPDATE.14Create a trigger that updates categories.post_count on post INSERT/UPDATE/DELETE.Pro tip: Ask Lovable to also add a tsvector column 'search_vector' to posts that indexes title + body using a GIN index. This enables fast full-text search later: ALTER TABLE posts ADD COLUMN search_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', coalesce(title,'') || ' ' || coalesce(body,''))) STORED.
Expected result: Both tables are created with triggers. TypeScript types are generated. The preview shows the app shell with navigation.
Build the posts DataTable with status Tabs
Ask Lovable to build the main admin page. A Tabs component at the top switches between All, Draft, and Published views. The DataTable shows all relevant columns and supports sorting and search.
1Build the CMS admin page at src/pages/Posts.tsx.23Layout:4- Page header with 'Content' title and a 'New Post' Button (primary)5- Tabs component with three triggers: All, Draft, Published. Each tab filters the posts query by status.6- Below tabs: a search Input that filters by title (client-side on fetched data)7- DataTable (TanStack Table) with columns: Checkbox (bulk select), Title (clickable, opens editor), Category (Badge with category color), Status (Badge: draft=gray, published=green, archived=orange), Published At (date or '—'), Updated At (relative time), Actions (Edit Button + Delete AlertDialog trigger)89Behavior:10- Clicking a row title OR the Edit button opens a full-screen Sheet (not a Dialog — posts need space)11- Sheet contains the post editor (covered in the next step)12- Bulk select shows a floating action bar at the bottom: 'Publish X posts', 'Archive X posts', 'Delete X posts'13- Delete uses an AlertDialog to confirm before calling supabase.from('posts').delete()1415Fetch posts with category name via join: supabase.from('posts').select('*, categories(name, color)')Pro tip: Set the DataTable default sort to updated_at DESC so your most recently edited posts always appear first. Add a column visibility toggle (Columns button) so admins can hide meta fields they rarely need.
Expected result: The posts page renders with Tabs and a DataTable. Switching tabs filters the list. The search input narrows results by title.
Build the post editor with image upload
Create the post editing Sheet that opens from the DataTable. It contains all post fields including a markdown textarea and a featured image uploader connected to Supabase Storage.
1Build a PostEditor component at src/components/PostEditor.tsx. It receives a post object (or null for new) and an onSave callback.23Form fields (react-hook-form + zod):4- Title (Input, required, triggers slug preview below it)5- Slug (Input, pre-filled from title, editable, validated unique format)6- Category (Select populated from categories table)7- Status (Select: Draft, Published, Archived)8- Excerpt (Textarea, max 300 chars, show char count)9- Body (Textarea with monospace font, full markdown, min 300px height)10- Meta Title (Input, show char count, warn if >60)11- Meta Description (Textarea, show char count, warn if >155)12- Featured Image: a file input that accepts image/*, uploads to Supabase Storage bucket 'cms-images' at path posts/{postId}/{filename}. After upload, store the public URL in featured_image_url. Show a preview thumbnail if URL exists.1314On save:15- If status changed to 'published' and published_at is null, set published_at = new Date().toISOString()16- Upsert to posts table17- Call onSave() to refresh the DataTable1819Add a 'Preview Markdown' toggle that switches the body Textarea to a rendered markdown preview div.Expected result: The post editor Sheet opens from the DataTable. Filling in the title auto-populates the slug. Image upload stores the file and shows a thumbnail. Saving updates the DataTable row.
Build the categories management page
Create a simple categories page where admins can add, edit, and delete categories. Deleting a category requires reassigning its posts first.
1Build a categories management page at src/pages/Categories.tsx.23Layout:4- Header with 'Categories' title and 'Add Category' Button5- Grid of Cards, one per category6- Each Card shows: category name (large), slug (small monospace), description, a colored Badge with post_count, Edit icon Button, Delete icon Button78Add Category Dialog (react-hook-form + zod):9- Name (Input, required)10- Slug (Input, auto-filled from name, editable)11- Description (Textarea)12- Color (a row of preset color swatches the user clicks to select, stores hex string)1314Delete behavior:15- If post_count > 0, show an AlertDialog warning: 'This category has X posts. Choose a replacement category before deleting.'16- Show a Select to pick a replacement category17- On confirm: UPDATE posts SET category_id = replacementId WHERE category_id = deletedId, then DELETE the categoryExpected result: The categories page shows all categories as Cards with post counts. Adding a category auto-fills the slug. Deleting a category with posts prompts for a replacement.
Create the public REST API Edge Function
Build a Supabase Edge Function that exposes a clean /posts endpoint for headless consumption. This is what your Next.js, Astro, or mobile app will call to fetch published content.
1// supabase/functions/cms-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 supabase = createClient(16 Deno.env.get('SUPABASE_URL') ?? '',17 Deno.env.get('SUPABASE_ANON_KEY') ?? ''18 )1920 const path = url.pathname.replace('/functions/v1/cms-api', '')2122 if (path === '/posts' || path === '/posts/') {23 const category = url.searchParams.get('category')24 const search = url.searchParams.get('q')25 const page = parseInt(url.searchParams.get('page') ?? '1')26 const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '10'), 50)27 const from = (page - 1) * limit2829 let query = supabase30 .from('posts')31 .select('id, title, slug, excerpt, featured_image_url, published_at, categories(name, slug)', { count: 'exact' })32 .eq('status', 'published')33 .order('published_at', { ascending: false })34 .range(from, from + limit - 1)3536 if (category) query = query.eq('categories.slug', category)37 if (search) query = query.textSearch('search_vector', search)3839 const { data, count, error } = await query40 if (error) return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: cors })41 return new Response(JSON.stringify({ data, meta: { page, limit, total: count } }), { headers: cors })42 }4344 const slugMatch = path.match(/^\/posts\/([\w-]+)$/)45 if (slugMatch) {46 const { data, error } = await supabase47 .from('posts')48 .select('*, categories(name, slug, color)')49 .eq('slug', slugMatch[1])50 .eq('status', 'published')51 .single()52 if (error || !data) return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })53 return new Response(JSON.stringify({ data }), { headers: cors })54 }5556 return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })57})Pro tip: The search_vector column makes ?q=keyword full-text search free. If you skipped it in Step 1, ask Lovable to add the migration now: 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);
Expected result: The Edge Function deploys. Fetching /functions/v1/cms-api/posts returns JSON with published posts and pagination metadata. Fetching /posts/{slug} returns a single post.
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 supabase = createClient(15 Deno.env.get('SUPABASE_URL') ?? '',16 Deno.env.get('SUPABASE_ANON_KEY') ?? ''17 )18 const path = url.pathname.replace('/functions/v1/cms-api', '')1920 if (path === '/posts' || path === '/posts/') {21 const category = url.searchParams.get('category')22 const search = 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, featured_image_url, published_at, categories(name, slug)', { count: 'exact' })30 .eq('status', 'published')31 .order('published_at', { ascending: false })32 .range(from, from + limit - 1)3334 if (category) query = query.eq('categories.slug', category)35 if (search) query = query.textSearch('search_vector', search)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('*, categories(name, slug, color)')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 return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })55})Customization ideas
Scheduled publish (publish_at)
Add a publish_at timestamptz column to posts. Create a scheduled Supabase Edge Function that runs every 5 minutes and updates status to 'published' for posts where publish_at <= now() and status = 'draft'. The post editor gets a DateTimePicker for scheduling.
Content versioning
Add a post_versions table that saves a snapshot of the post body and title on every update. The editor shows a 'Version History' tab listing saved versions with timestamps. Clicking a version shows a diff preview and a 'Restore' button.
Multi-author support
Add an authors table linked to auth.users with display name, bio, and avatar. Show author Select in the post editor. The public API returns author details in every post response. Build an /authors/{slug} public page showing all posts by that author.
Media library
Add a dedicated media library page that lists all files in the cms-images Storage bucket. Show thumbnails in a grid with file size and upload date. Add a global image picker Dialog that any field in the editor can open to select an existing uploaded image.
Webhook on publish
When a post status changes to 'published', fire an Edge Function that calls a configured webhook URL (stored in a settings table). This lets you trigger Next.js ISR revalidation, notify Slack, or ping a CDN purge endpoint automatically.
Common pitfalls
Pitfall: Not setting published_at when status changes to published
How to avoid: In the PostEditor onSave logic, check if status === 'published' && !post.published_at and set published_at = new Date().toISOString() before upserting. Ask Lovable to add this logic explicitly.
Pitfall: Uploading images using the anon key without Storage RLS
How to avoid: In Supabase Dashboard → Storage → Policies, add a policy: allow INSERT for authenticated users only. For the cms-images bucket, the select policy can remain public (so CDN URLs work), but insert must require auth.
Pitfall: Not slugifying the category slug field
How to avoid: Ask Lovable to add a slugify helper that runs on category and post name input: value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, ''). Apply it in the trigger and the frontend form onChange handler.
Pitfall: Storing featured images as base64 in the database
How to avoid: Always upload to Supabase Storage and store only the public URL string in featured_image_url. The Storage bucket handles CDN delivery and scaling.
Best practices
- Keep the markdown body field as plain text in the database. Parse it to HTML only at render time — either in the frontend or in the Edge Function response. Never store rendered HTML.
- Add a soft-delete flag (deleted_at timestamptz) instead of hard-deleting posts. Deleted posts can be restored and internal links do not break immediately.
- Use a separate meta_title and meta_description field for SEO rather than truncating title and excerpt. These fields serve different purposes and often need different phrasing.
- Set a maximum body size limit in the Supabase table (or at the form level) to prevent runaway storage use. 100KB per post body is a reasonable upper limit for most CMS use cases.
- Always test your public API Edge Function with a curl call from outside Lovable before connecting a frontend. This confirms RLS is configured correctly and the response shape is what you expect.
- Add created_by and updated_by columns to posts that reference auth.users. This enables per-author audit trails and restricts writers to editing only their own posts.
AI prompts to try
Copy these prompts to build this project faster.
I have a headless CMS built on Supabase. The posts table has title, body (markdown), slug, status, published_at, and a search_vector tsvector column. I want to build a Next.js App Router page that fetches posts via the Supabase JS client on the server side. Show me the TypeScript code for a server component that lists published posts with pagination (10 per page), full-text search via the search_vector column using textSearch(), and static generation with revalidation every 60 seconds using Next.js fetch cache.
Add a 'Related Posts' section to the post editor Sheet. When a post is open for editing, fetch 5 posts from the same category (excluding the current one) and show them as a list of checkboxes. The selected posts get saved to a post_relations junction table (post_id, related_post_id). The public API Edge Function should include related posts in the single-post response.
In Supabase, create a database function that accepts a post_id and returns the next and previous published posts by published_at. The function should return: previous_post (id, title, slug) and next_post (id, title, slug). I'll call this from my Edge Function to add pagination links to the single post API response.
Frequently asked questions
Can I use this CMS to power a Next.js or Astro frontend?
Yes. The Edge Function returns standard JSON that any frontend framework can consume. In Next.js, call the Edge Function URL from a server component using fetch() with the Next.js cache options. In Astro, call it in your frontmatter. The response includes all post fields including the markdown body, which you parse to HTML on the frontend using a library like react-markdown or marked.
How do I handle images in the markdown body?
Upload images to the cms-images Supabase Storage bucket and paste the public CDN URL into the markdown body as standard markdown image syntax: . The image is served directly from Supabase Storage's CDN — no additional handling needed.
Is there a way to preview a draft post before publishing?
Add a preview parameter to the Edge Function: if the request includes ?preview=true and a valid session token, the function returns draft posts too. In Lovable, add a 'Preview' button to the editor that opens the post slug in a new tab with the preview parameter. Keep the preview token secret and rotate it periodically.
What happens if two admins edit the same post at the same time?
The last save wins by default. To prevent overwrites, add an updated_at check: when saving, compare the post's updated_at from the database with the value loaded when the editor opened. If they differ, show a conflict warning and let the admin choose to overwrite or reload. Ask Lovable to add this optimistic concurrency check to the PostEditor.
Can I add custom fields to posts, like a featured flag or a reading time?
Yes. Ask Lovable to add columns to the posts table via a SQL migration. For reading_time, add a computed column: ALTER TABLE posts ADD COLUMN reading_time int GENERATED ALWAYS AS (greatest(1, round(array_length(regexp_split_to_array(trim(body), '\s+'), 1) / 200.0))) STORED. For a featured bool column, add a Featured Switch to the editor form.
How do I migrate content from an existing CMS like WordPress?
Export your WordPress content as a JSON or CSV file. Write a one-time migration script (or ask ChatGPT to generate it) that maps WordPress post fields to your Supabase schema and uses the Supabase service role key to insert rows. For images, download them to Supabase Storage and update the featured_image_url values in the same script.
Does the CMS support multiple languages?
Not out of the box, but you can add it. Ask Lovable to add a locale column (text, e.g. 'en', 'es', 'fr') to posts and a unique constraint on (slug, locale). The API endpoint accepts a ?locale=en parameter and filters by it. Each language version of a post gets its own slug and body but shares the same category structure.
Can I get help setting this up with my existing frontend?
RapidDev connects Lovable-built backends to any frontend framework. If you need help wiring the CMS API to a Next.js or Astro site, reach out for a consultation.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation