Build a headless blog backend API in Replit in 30-60 minutes using Express, PostgreSQL, and Drizzle ORM. You'll get CRUD endpoints for posts, categories, and tags, plus a public read API with full-text search — ready to power any frontend framework.
What you're building
A headless blog backend separates your content management from the presentation layer. The API handles all the data — posts, authors, categories, tags — and any frontend framework (React, Next.js, or even a mobile app) can consume it. This approach gives you flexibility to redesign your blog UI without touching the backend.
Replit Agent generates the complete Express + Drizzle foundation in minutes. Because the app uses Replit's built-in PostgreSQL, you don't configure a database connection — it's already available. Replit Auth handles admin authentication so you can write and publish posts immediately without building a login system.
The architecture is two-tier: public routes (no auth) serve your blog readers, and admin routes (Replit Auth required) let you create, edit, and manage content. A full-text search index on posts makes the blog searchable out of the box.
Final result
A deployed headless blog API with public post/category/tag endpoints, a draft/publish workflow, full-text search, and an admin panel for writing and managing posts.
Tech stack
Prerequisites
- A Replit account (free tier is sufficient for this guide)
- Basic understanding of what a REST API is (no coding experience needed)
- Optional: decide your post structure (categories, tags, or both) before building
Build steps
Generate the blog backend with Replit Agent
One detailed prompt to Agent creates the complete Express + Drizzle project with the blog schema, routes, and React admin panel. Specificity in the prompt means less cleanup work afterward.
1// Prompt to type into Replit Agent:2// Build a Node.js Express headless blog backend with Replit Auth and built-in PostgreSQL using Drizzle ORM.3// Schema in shared/schema.ts:4// * authors: id serial pk, user_id text not null unique, name text not null,5// bio text, avatar_url text, created_at timestamp default now()6// * posts: id serial pk, author_id integer references authors not null,7// title text not null, slug text not null unique, excerpt text,8// body text not null, cover_image_url text, status text default 'draft',9// published_at timestamp, created_at timestamp default now(), updated_at timestamp default now()10// * categories: id serial pk, name text not null unique, slug text not null unique, description text11// * post_categories: id serial pk, post_id integer references posts not null,12// category_id integer references categories not null, unique on (post_id, category_id)13// * tags: id serial pk, name text not null unique, slug text not null unique14// * post_tags: id serial pk, post_id integer references posts not null,15// tag_id integer references tags not null, unique on (post_id, tag_id)16// Public routes (no auth): GET /api/posts, GET /api/posts/:slug, GET /api/categories, GET /api/tags17// Admin routes (Replit Auth): POST/PUT /api/admin/posts, PATCH /api/admin/posts/:id/publish,18// DELETE /api/admin/posts/:id, POST /api/admin/categories, POST /api/admin/tags19// React frontend: public blog with post list and single post view, admin panel with post editorPro tip: If Agent generates the admin panel without markdown preview in the post editor, ask it to 'Add a live markdown preview panel next to the body textarea using the marked library'.
Expected result: Express server running with all routes. Drizzle Studio shows all six tables. The public blog and admin panel load in the Webview.
Add the slugify helper and auto-slug generation
Every post needs a URL-safe slug derived from its title. Build a slugify helper and add uniqueness validation — if 'my-post' already exists, generate 'my-post-2', 'my-post-3', etc.
1import { db } from '../db.js';2import { posts } from '../../shared/schema.js';3import { like, sql } from 'drizzle-orm';45export function slugify(text) {6 return text7 .toLowerCase()8 .replace(/[^\w\s-]/g, '') // remove non-word chars9 .replace(/[\s_-]+/g, '-') // replace spaces/underscores with hyphens10 .replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens11}1213export async function generateUniqueSlug(title) {14 const baseSlug = slugify(title);1516 // Check for existing slugs that start with baseSlug17 const existing = await db18 .select({ slug: posts.slug })19 .from(posts)20 .where(like(posts.slug, `${baseSlug}%`));2122 if (existing.length === 0) return baseSlug;2324 // Find the highest suffix number25 const suffixes = existing26 .map(r => r.slug)27 .filter(s => s === baseSlug || s.match(new RegExp(`^${baseSlug}-\\d+$`)))28 .map(s => {29 if (s === baseSlug) return 0;30 return parseInt(s.replace(`${baseSlug}-`, '')) || 0;31 });3233 const maxSuffix = Math.max(...suffixes);34 return `${baseSlug}-${maxSuffix + 1}`;35}Pro tip: Use the slug (not the ID) in all public URLs. Slugs are human-readable, SEO-friendly, and don't expose database internals. When updating a post title, check if the user wants to update the slug too — changing the slug breaks existing links.
Expected result: Creating a post titled 'My First Post' generates slug 'my-first-post'. Creating another post with the same title generates 'my-first-post-2'.
Build the public post list endpoint with category and tag filtering
The public GET /api/posts endpoint is what your blog frontend calls. It joins posts with authors, categories, and tags in a single query — no N+1 queries — and supports pagination and filtering.
1import { db } from '../db.js';2import { posts, authors, categories, postCategories, tags, postTags } from '../../shared/schema.js';3import { eq, and, inArray, desc, count, sql } from 'drizzle-orm';45export async function listPublishedPosts(req, res) {6 const page = Math.max(1, parseInt(req.query.page) || 1);7 const limit = Math.min(50, parseInt(req.query.limit) || 10);8 const offset = (page - 1) * limit;9 const categorySlug = req.query.category;10 const tagSlug = req.query.tag;11 const searchQuery = req.query.q;1213 try {14 // Build base query: published posts only15 let whereConditions = [eq(posts.status, 'published')];1617 // Full-text search18 if (searchQuery) {19 whereConditions.push(20 sql`to_tsvector('english', ${posts.title} || ' ' || ${posts.body}) @@ plainto_tsquery('english', ${searchQuery})`21 );22 }2324 // Filter by category slug25 if (categorySlug) {26 const [cat] = await db.select({ id: categories.id }).from(categories).where(eq(categories.slug, categorySlug));27 if (cat) {28 const postIds = await db.select({ postId: postCategories.postId }).from(postCategories).where(eq(postCategories.categoryId, cat.id));29 whereConditions.push(inArray(posts.id, postIds.map(r => r.postId)));30 }31 }3233 const condition = and(...whereConditions);34 const [{ total }] = await db.select({ total: count() }).from(posts).where(condition);3536 const results = await db37 .select({38 id: posts.id, title: posts.title, slug: posts.slug,39 excerpt: posts.excerpt, coverImageUrl: posts.coverImageUrl,40 publishedAt: posts.publishedAt,41 authorName: authors.name, authorAvatarUrl: authors.avatarUrl,42 })43 .from(posts)44 .leftJoin(authors, eq(posts.authorId, authors.id))45 .where(condition)46 .orderBy(desc(posts.publishedAt))47 .limit(limit)48 .offset(offset);4950 res.json({51 data: results,52 pagination: { page, limit, total: Number(total), totalPages: Math.ceil(Number(total) / limit) },53 });54 } catch (err) {55 res.status(500).json({ error: 'Failed to fetch posts' });56 }57}Pro tip: The full-text search uses plainto_tsquery (safer for user input) rather than to_tsquery (which requires exact tsquery syntax). After creating a few posts, run this SQL in Drizzle Studio to add the GIN index: CREATE INDEX posts_search ON posts USING GIN (to_tsvector('english', title || ' ' || body));
Expected result: GET /api/posts returns paginated published posts with author info. GET /api/posts?q=javascript searches by full-text. GET /api/posts?category=tutorials filters by category.
Add the publish/draft workflow
Posts start as drafts (visible only in admin). Publishing sets status to 'published' and records the published_at timestamp. Archiving hides a post from the public without deleting it.
1import { db } from '../db.js';2import { posts } from '../../shared/schema.js';3import { eq } from 'drizzle-orm';45// PATCH /api/admin/posts/:id/publish6export async function publishPost(req, res) {7 const { id } = req.params;8 const [updated] = await db9 .update(posts)10 .set({ status: 'published', publishedAt: new Date(), updatedAt: new Date() })11 .where(eq(posts.id, parseInt(id)))12 .returning();1314 if (!updated) return res.status(404).json({ error: 'Post not found' });15 res.json(updated);16}1718// PATCH /api/admin/posts/:id/unpublish19export async function unpublishPost(req, res) {20 const { id } = req.params;21 const [updated] = await db22 .update(posts)23 .set({ status: 'draft', updatedAt: new Date() })24 .where(eq(posts.id, parseInt(id)))25 .returning();2627 if (!updated) return res.status(404).json({ error: 'Post not found' });28 res.json(updated);29}3031// DELETE /api/admin/posts/:id — soft delete (archive)32export async function archivePost(req, res) {33 const { id } = req.params;34 const [updated] = await db35 .update(posts)36 .set({ status: 'archived', updatedAt: new Date() })37 .where(eq(posts.id, parseInt(id)))38 .returning();3940 if (!updated) return res.status(404).json({ error: 'Post not found' });41 res.json({ success: true, post: updated });42}Expected result: The admin panel shows a Publish button on draft posts. Clicking it calls PATCH /api/admin/posts/:id/publish and the post immediately appears in the public GET /api/posts response.
Complete code
1import { db } from '../db.js';2import { posts } from '../../shared/schema.js';3import { like } from 'drizzle-orm';45export function slugify(text) {6 return text7 .toString()8 .toLowerCase()9 .trim()10 .replace(/[^\w\s-]/g, '')11 .replace(/[\s_-]+/g, '-')12 .replace(/^-+|-+$/g, '');13}1415export async function generateUniqueSlug(title, excludePostId = null) {16 const base = slugify(title);17 if (!base) throw new Error('Title produces an empty slug');1819 const existing = await db20 .select({ slug: posts.slug })21 .from(posts)22 .where(like(posts.slug, `${base}%`));2324 const taken = existing25 .map(r => r.slug)26 .filter(s => !excludePostId); // allow reuse of own slug on update2728 if (!taken.includes(base)) return base;2930 let counter = 2;31 while (taken.includes(`${base}-${counter}`)) counter++;32 return `${base}-${counter}`;33}Customization ideas
RSS feed endpoint
Add a GET /feed.xml endpoint that returns an RSS 2.0 XML document with the 20 most recent published posts. Include title, link, description (excerpt), pubDate, and author. Use the blog title and URL from app_settings.
Reading time estimation
Add a read_time_minutes computed field to post responses. Calculate it server-side as Math.ceil(wordCount / 200) where wordCount is body.split(/\s+/).length. Store it in the posts table and update on every body change.
Post view counter
Add a view_count integer column to posts. In the GET /api/posts/:slug handler, run a non-blocking UPDATE posts SET view_count = view_count + 1. Add a top-posts endpoint that sorts by view_count descending.
Common pitfalls
Pitfall: Returning draft posts from the public endpoint
How to avoid: Always add eq(posts.status, 'published') to public list and detail queries. Only admin routes should query drafts.
Pitfall: N+1 queries when fetching posts with categories and tags
How to avoid: Use Drizzle's leftJoin to fetch authors in the same query. For categories and tags, fetch all junction table entries for the returned post IDs in a second query, then merge client-side.
Pitfall: Changing the slug when updating a post title
How to avoid: Generate the slug once on post creation. On update, only regenerate the slug if the user explicitly requests it via a 'Change URL slug' checkbox in the admin form.
Best practices
- Generate slugs at creation time and never auto-change them on title updates — broken links hurt SEO.
- Return only the fields needed for each endpoint: full body for single-post view, excerpt for list view.
- Use plainto_tsquery (not to_tsquery) for user-facing search input — it's more forgiving of irregular input.
- Always filter public endpoints by status = 'published' to prevent draft content from leaking.
- Deploy on Autoscale — blogs have spiky traffic (social shares, HN posts) and scale-to-zero is cost-effective between bursts.
- Add the GIN full-text search index after populating some content: CREATE INDEX posts_search ON posts USING GIN (to_tsvector('english', title || ' ' || body)).
- Soft-delete posts (status = 'archived') rather than hard-deleting — you may want to recover content later.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a headless blog API with Express and PostgreSQL using Drizzle ORM. I have a posts table with a body column containing markdown text. Help me write a GET /api/posts endpoint that: (1) only returns published posts, (2) joins with the authors table to include author name and avatar, (3) supports pagination with page and limit query params, (4) supports full-text search with a q parameter using PostgreSQL's tsvector, and (5) supports filtering by category slug using a join through a post_categories junction table.
Add a scheduled post feature to the blog. Add a scheduled_at timestamp column to the posts table. An admin can set a future scheduled_at when creating/editing a post. A setInterval running every minute on Reserved VM checks for posts where scheduled_at <= now() and status = 'scheduled', then sets their status to 'published' and published_at to now(). Show scheduled posts in the admin panel with a 'Scheduled for [date]' badge.
Frequently asked questions
Can the free Replit tier handle a real blog with decent traffic?
Yes for moderate traffic (a few hundred daily visitors). The free tier includes Express hosting and the built-in PostgreSQL. The main limitation is that the app sleeps after inactivity, causing a 5-10 second cold start for the first visitor. Deploy on Replit Core's Autoscale for faster cold starts and more consistent performance.
How do I add images to blog posts?
Store image URLs in the cover_image_url column. For uploads, use a third-party storage service: Cloudflare R2 (cheapest), AWS S3, or Uploadthing. Store the API credentials in Replit Secrets and build a POST /api/admin/upload endpoint that accepts a multipart form, uploads to your storage service, and returns the public URL.
Can multiple authors publish on this blog?
Yes. Each Replit account that logs in creates a row in the authors table. The author_id on the posts table links each post to its author. Adjust the admin UI to show only the current user's drafts by default, while admins can see all authors' posts.
How do I connect a custom domain to the deployed blog?
Replit supports custom domains on paid plans. In your Replit deployment settings, go to Networking → Custom Domains → Add domain. You'll need to add a CNAME DNS record at your domain registrar pointing to your Replit deployment URL.
Why does the first blog visitor after idle get a slow response?
Replit's built-in PostgreSQL sleeps after 5 minutes of no queries. The first request triggers a reconnection (1-3 seconds). Wrap database calls in a withRetry() function that catches ECONNRESET and retries after 500ms. This is transparent to the visitor — they just see a slightly slower first load.
Can RapidDev help me build a custom blog or content platform?
Yes. RapidDev has built 600+ apps including content platforms with custom publishing workflows, multi-author management, and headless CMS features. Contact us for a free consultation about your specific content needs.
How do I add comments to blog posts?
Add a comments table (id serial pk, post_id integer references posts, author_name text, author_email text, body text, status text default 'pending', created_at timestamp). Add GET /api/posts/:slug/comments (approved comments) and POST /api/posts/:slug/comments (creates with status 'pending'). Add a comment moderation view in the admin panel.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation