Skip to main content
RapidDev - Software Development Agency

How to Build a Simple CMS with Lovable

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'll build

  • Posts and categories tables with draft/published status, slug auto-generation, and markdown body
  • Supabase Storage bucket for featured images with public CDN URLs stored in posts
  • Admin DataTable showing all posts with sortable columns and inline status Tabs (All / Draft / Published)
  • Rich post editor with a markdown Textarea, category Select, status Toggle, and image upload
  • Public reads + authenticated writes RLS so your frontend can fetch posts without auth
  • A Supabase Edge Function exposing a /posts REST endpoint for headless delivery
  • Category management page with post counts and bulk-reassign before delete
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate14 min read2–2.5 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

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

LovableAdmin frontend
SupabaseDatabase and Auth
Supabase StorageFeatured image uploads
Supabase Edge FunctionsPublic REST API (Deno)
shadcn/uiDataTable, Tabs, Dialog, Form components
TanStack Table v8Admin posts DataTable

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

1

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.

prompt.txt
1Create a headless CMS with Supabase. Set up these tables:
2
3- 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_at
4- 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_at
5
6RLS:
7- posts SELECT: allow anon and authenticated for status = 'published'
8- posts INSERT/UPDATE/DELETE: allow authenticated users only
9- categories SELECT: allow anon and authenticated
10- categories INSERT/UPDATE/DELETE: allow authenticated only
11
12Create 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.

2

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.

prompt.txt
1Build the CMS admin page at src/pages/Posts.tsx.
2
3Layout:
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)
8
9Behavior:
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()
14
15Fetch 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.

3

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.

prompt.txt
1Build a PostEditor component at src/components/PostEditor.tsx. It receives a post object (or null for new) and an onSave callback.
2
3Form 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.
13
14On save:
15- If status changed to 'published' and published_at is null, set published_at = new Date().toISOString()
16- Upsert to posts table
17- Call onSave() to refresh the DataTable
18
19Add 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.

4

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.

prompt.txt
1Build a categories management page at src/pages/Categories.tsx.
2
3Layout:
4- Header with 'Categories' title and 'Add Category' Button
5- Grid of Cards, one per category
6- Each Card shows: category name (large), slug (small monospace), description, a colored Badge with post_count, Edit icon Button, Delete icon Button
7
8Add 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)
13
14Delete 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 category
17- On confirm: UPDATE posts SET category_id = replacementId WHERE category_id = deletedId, then DELETE the category

Expected 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.

5

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.

supabase/functions/cms-api/index.ts
1// supabase/functions/cms-api/index.ts
2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
4
5const cors = {
6 'Access-Control-Allow-Origin': '*',
7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
8 'Content-Type': 'application/json',
9}
10
11serve(async (req: Request) => {
12 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })
13
14 const url = new URL(req.url)
15 const supabase = createClient(
16 Deno.env.get('SUPABASE_URL') ?? '',
17 Deno.env.get('SUPABASE_ANON_KEY') ?? ''
18 )
19
20 const path = url.pathname.replace('/functions/v1/cms-api', '')
21
22 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) * limit
28
29 let query = supabase
30 .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)
35
36 if (category) query = query.eq('categories.slug', category)
37 if (search) query = query.textSearch('search_vector', search)
38
39 const { data, count, error } = await query
40 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 }
43
44 const slugMatch = path.match(/^\/posts\/([\w-]+)$/)
45 if (slugMatch) {
46 const { data, error } = await supabase
47 .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 }
55
56 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

supabase/functions/cms-api/index.ts
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
3
4const cors = {
5 'Access-Control-Allow-Origin': '*',
6 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
7 'Content-Type': 'application/json',
8}
9
10serve(async (req: Request) => {
11 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })
12
13 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', '')
19
20 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) * limit
26
27 let query = supabase
28 .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)
33
34 if (category) query = query.eq('categories.slug', category)
35 if (search) query = query.textSearch('search_vector', search)
36
37 const { data, count, error } = await query
38 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 }
41
42 const slugMatch = path.match(/^\/posts\/([\w-]+)$/)
43 if (slugMatch) {
44 const { data, error } = await supabase
45 .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 }
53
54 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.

ChatGPT Prompt

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.

Lovable Prompt

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.

Build Prompt

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: ![alt text](https://your-project.supabase.co/storage/v1/object/public/cms-images/filename.jpg). 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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.