# How to Build a Blog Backend with Lovable

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

## TL;DR

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.

## Before you start

- 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

## Step-by-step guide

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

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

- tags: id (uuid pk), name (text unique not null), slug (text unique not null), color (text, hex), created_at
- 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_at
- post_tags: post_id (uuid references posts on delete cascade), tag_id (uuid references tags on delete cascade), PRIMARY KEY (post_id, tag_id)

RLS:
- posts: anon SELECT where status = 'published', authenticated SELECT all, authenticated INSERT/UPDATE/DELETE
- tags: anon SELECT, authenticated INSERT/UPDATE/DELETE
- post_tags: anon SELECT, authenticated INSERT/DELETE

Create a trigger on posts BEFORE INSERT that:
1. Generates slug from title: lowercase, non-alphanumeric to hyphens, trim hyphens
2. If slug already exists, append -2, -3, etc. until unique

Create a trigger BEFORE UPDATE that sets updated_at = now().

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

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

```
Build a PostEditor component at src/components/PostEditor.tsx used for both new posts and editing existing ones. Accept props: post (Post | null), onSave callback.

Form fields (react-hook-form + zod):
- Title (Input required — slug preview appears beneath it in small text)
- Status (Select: Draft / Published)
- Tags (multi-select using a Command popover with Checkboxes for each tag, showing selected tags as Badges below)
- Excerpt (Textarea, max 250 chars, show char count)
- Body (Textarea, monospace, minimum height 400px, placeholder: 'Write in markdown...')
- Cover Image: file input (image/* only). On file select:
  1. Upload to Supabase Storage bucket 'blog-images' at path posts/{post.id || 'new'}/{filename}
  2. Get public URL
  3. Store in cover_image_url field
  4. Show a thumbnail preview

On save:
- If status changes to 'published' and published_at is null, set published_at = new Date().toISOString()
- Upsert post
- Delete existing post_tags for this post, then insert new ones
- Show a toast: 'Post saved'

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

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

```
Build the main blog admin page at src/pages/Blog.tsx.

Layout:
- Page header 'Posts' with a 'New Post' Button that opens PostEditor in a Sheet
- Tag filter row: a horizontal list of Badge buttons, one per tag plus an 'All' Badge. Clicking a tag filters the DataTable.
- Search Input that filters by title client-side
- DataTable (TanStack Table) with columns:
  - Title (clickable text, opens editor Sheet)
  - Tags (row of small Badges)
  - Status (Badge: draft=gray, published=green)
  - Read Time (e.g. '3 min read')
  - Published At (date string or '—')
  - Actions (Edit Button, Delete AlertDialog)

Fetch with tags: supabase.from('posts').select('*, post_tags(tags(id, name, color))')

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

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

```
// supabase/functions/blog-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 path = url.pathname.replace('/functions/v1/blog-api', '')
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_ANON_KEY') ?? ''
  )

  if (path === '/posts' || path === '/posts/') {
    const tag = url.searchParams.get('tag')
    const q = 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, cover_image_url, published_at, read_time_minutes, post_tags(tags(name, slug, color))', { count: 'exact' })
      .eq('status', 'published')
      .order('published_at', { ascending: false })
      .range(from, from + limit - 1)

    if (tag) query = query.eq('post_tags.tags.slug', tag)
    if (q) query = query.textSearch('search_vector', q)

    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('*, post_tags(tags(*))')
      .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 })
  }

  if (path === '/tags') {
    const { data, error } = await supabase.from('tags').select('*').order('name')
    if (error) return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: cors })
    return new Response(JSON.stringify({ data }), { headers: cors })
  }

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

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

File: `supabase/functions/blog-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 path = url.pathname.replace('/functions/v1/blog-api', '')
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_ANON_KEY') ?? ''
  )

  if (path === '/posts' || path === '/posts/') {
    const tag = url.searchParams.get('tag')
    const q = 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, cover_image_url, published_at, read_time_minutes, post_tags(tags(name, slug, color))', { count: 'exact' })
      .eq('status', 'published')
      .order('published_at', { ascending: false })
      .range(from, from + limit - 1)

    if (tag) query = query.eq('post_tags.tags.slug', tag)
    if (q) query = query.textSearch('search_vector', q)

    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('*, post_tags(tags(*))')
      .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 })
  }

  if (path === '/tags') {
    const { data, error } = await supabase.from('tags').select('*').order('name')
    if (error) return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: cors })
    return new Response(JSON.stringify({ data }), { headers: cors })
  }

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

## Common mistakes

- **Forgetting to set published_at when changing status to published** — The API sorts by published_at. If it is null, the post either sorts incorrectly or the client has to fall back to created_at, causing inconsistent ordering. Fix: In the editor save handler, check: if (status === 'published' && !post.published_at) then set published_at = new Date().toISOString() before the upsert call.
- **Not cascading deletes from post_tags when a post is deleted** — Deleting a post without cascade leaves orphan rows in post_tags. These pile up over time and can cause query errors. Fix: 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.
- **Using the anon key in the Edge Function to write data** — The anon key respects RLS. If you accidentally call an INSERT in the Edge Function using the anon key, it will be blocked by RLS unless you specifically allowed it for anon. Fix: 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.
- **Forgetting to add an index on the slug column causing slow page lookups** — Every request to /posts/{slug} queries the posts table by slug. Without an index, PostgreSQL performs a sequential scan across all rows on each lookup, which becomes noticeably slow as the post count grows. Fix: 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.

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

---

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