# How to Build a Simple CMS with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

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

## Before you start

- 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

## Step-by-step guide

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

```
Create a headless CMS with Supabase. Set up these tables:

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

RLS:
- posts SELECT: allow anon and authenticated for status = 'published'
- posts INSERT/UPDATE/DELETE: allow authenticated users only
- categories SELECT: allow anon and authenticated
- categories INSERT/UPDATE/DELETE: allow authenticated only

Create a trigger on posts that auto-generates slug from title (lowercase, spaces to hyphens, remove special chars) if slug is empty on INSERT.
Create a trigger that sets updated_at = now() on every UPDATE.
Create 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.

```
Build the CMS admin page at src/pages/Posts.tsx.

Layout:
- Page header with 'Content' title and a 'New Post' Button (primary)
- Tabs component with three triggers: All, Draft, Published. Each tab filters the posts query by status.
- Below tabs: a search Input that filters by title (client-side on fetched data)
- 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)

Behavior:
- Clicking a row title OR the Edit button opens a full-screen Sheet (not a Dialog — posts need space)
- Sheet contains the post editor (covered in the next step)
- Bulk select shows a floating action bar at the bottom: 'Publish X posts', 'Archive X posts', 'Delete X posts'
- Delete uses an AlertDialog to confirm before calling supabase.from('posts').delete()

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

```
Build a PostEditor component at src/components/PostEditor.tsx. It receives a post object (or null for new) and an onSave callback.

Form fields (react-hook-form + zod):
- Title (Input, required, triggers slug preview below it)
- Slug (Input, pre-filled from title, editable, validated unique format)
- Category (Select populated from categories table)
- Status (Select: Draft, Published, Archived)
- Excerpt (Textarea, max 300 chars, show char count)
- Body (Textarea with monospace font, full markdown, min 300px height)
- Meta Title (Input, show char count, warn if >60)
- Meta Description (Textarea, show char count, warn if >155)
- 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.

On save:
- If status changed to 'published' and published_at is null, set published_at = new Date().toISOString()
- Upsert to posts table
- Call onSave() to refresh the DataTable

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

```
Build a categories management page at src/pages/Categories.tsx.

Layout:
- Header with 'Categories' title and 'Add Category' Button
- Grid of Cards, one per category
- Each Card shows: category name (large), slug (small monospace), description, a colored Badge with post_count, Edit icon Button, Delete icon Button

Add Category Dialog (react-hook-form + zod):
- Name (Input, required)
- Slug (Input, auto-filled from name, editable)
- Description (Textarea)
- Color (a row of preset color swatches the user clicks to select, stores hex string)

Delete behavior:
- If post_count > 0, show an AlertDialog warning: 'This category has X posts. Choose a replacement category before deleting.'
- Show a Select to pick a replacement category
- 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
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const cors = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })

  const url = new URL(req.url)
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_ANON_KEY') ?? ''
  )

  const path = url.pathname.replace('/functions/v1/cms-api', '')

  if (path === '/posts' || path === '/posts/') {
    const category = url.searchParams.get('category')
    const search = url.searchParams.get('q')
    const page = parseInt(url.searchParams.get('page') ?? '1')
    const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '10'), 50)
    const from = (page - 1) * limit

    let query = supabase
      .from('posts')
      .select('id, title, slug, excerpt, featured_image_url, published_at, categories(name, slug)', { count: 'exact' })
      .eq('status', 'published')
      .order('published_at', { ascending: false })
      .range(from, from + limit - 1)

    if (category) query = query.eq('categories.slug', category)
    if (search) query = query.textSearch('search_vector', search)

    const { data, count, error } = await query
    if (error) return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: cors })
    return new Response(JSON.stringify({ data, meta: { page, limit, total: count } }), { headers: cors })
  }

  const slugMatch = path.match(/^\/posts\/([\w-]+)$/)
  if (slugMatch) {
    const { data, error } = await supabase
      .from('posts')
      .select('*, categories(name, slug, color)')
      .eq('slug', slugMatch[1])
      .eq('status', 'published')
      .single()
    if (error || !data) return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
    return new Response(JSON.stringify({ data }), { headers: cors })
  }

  return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
})
```

> 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 example

File: `supabase/functions/cms-api/index.ts`

```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const cors = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })

  const url = new URL(req.url)
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_ANON_KEY') ?? ''
  )
  const path = url.pathname.replace('/functions/v1/cms-api', '')

  if (path === '/posts' || path === '/posts/') {
    const category = url.searchParams.get('category')
    const search = url.searchParams.get('q')
    const page = parseInt(url.searchParams.get('page') ?? '1')
    const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '10'), 50)
    const from = (page - 1) * limit

    let query = supabase
      .from('posts')
      .select('id, title, slug, excerpt, featured_image_url, published_at, categories(name, slug)', { count: 'exact' })
      .eq('status', 'published')
      .order('published_at', { ascending: false })
      .range(from, from + limit - 1)

    if (category) query = query.eq('categories.slug', category)
    if (search) query = query.textSearch('search_vector', search)

    const { data, count, error } = await query
    if (error) return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: cors })
    return new Response(JSON.stringify({ data, meta: { page, limit, total: count } }), { headers: cors })
  }

  const slugMatch = path.match(/^\/posts\/([\w-]+)$/)
  if (slugMatch) {
    const { data, error } = await supabase
      .from('posts')
      .select('*, categories(name, slug, color)')
      .eq('slug', slugMatch[1])
      .eq('status', 'published')
      .single()
    if (error || !data) return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
    return new Response(JSON.stringify({ data }), { headers: cors })
  }

  return new Response(JSON.stringify({ error: 'Not found' }), { status: 404, headers: cors })
})
```

## Common mistakes

- **Not setting published_at when status changes to published** — The published_at timestamp is what the public API uses to sort posts chronologically. If it is null, published posts appear in random order or sort to the end of the list. Fix: 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.
- **Uploading images using the anon key without Storage RLS** — Without Storage RLS, any visitor to your app can upload files to your bucket, rapidly consuming your free 1GB Storage quota. Fix: 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.
- **Not slugifying the category slug field** — If the slug contains spaces or uppercase letters, the API URL breaks and the frontend routing fails. Fix: 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.
- **Storing featured images as base64 in the database** — Base64 encoding inflates file size by 33%. Storing large images in PostgreSQL text columns bloats the database and makes list queries slow. Fix: 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.

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

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/simple-cms
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/simple-cms
