Skip to main content
RapidDev - Software Development Agency
how-to-build-replit30-60 minutes

How to Build a Blog Backend with Replit

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

  • Express REST API with public read endpoints for posts, categories, and tags
  • PostgreSQL schema via Drizzle ORM with authors, posts, categories, tags, and junction tables
  • Auto-slug generation from post titles with uniqueness validation
  • Publish/draft workflow with a PATCH endpoint to set published_at timestamp
  • Full-text search using PostgreSQL's GIN index on title and body (to_tsvector)
  • Admin routes protected by Replit Auth for creating, editing, and archiving posts
  • Paginated post list API with category and tag filtering
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner11 min read30-60 minutesReplit FreeApril 2026RapidDev Engineering Team
TL;DR

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

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
Replit AuthAuth (admin only)
ReactFrontend (admin + public blog)

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

1

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.

prompt.txt
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 text
11// * 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 unique
14// * 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/tags
17// 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/tags
19// React frontend: public blog with post list and single post view, admin panel with post editor

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

2

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.

server/utils/slugify.js
1import { db } from '../db.js';
2import { posts } from '../../shared/schema.js';
3import { like, sql } from 'drizzle-orm';
4
5export function slugify(text) {
6 return text
7 .toLowerCase()
8 .replace(/[^\w\s-]/g, '') // remove non-word chars
9 .replace(/[\s_-]+/g, '-') // replace spaces/underscores with hyphens
10 .replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
11}
12
13export async function generateUniqueSlug(title) {
14 const baseSlug = slugify(title);
15
16 // Check for existing slugs that start with baseSlug
17 const existing = await db
18 .select({ slug: posts.slug })
19 .from(posts)
20 .where(like(posts.slug, `${baseSlug}%`));
21
22 if (existing.length === 0) return baseSlug;
23
24 // Find the highest suffix number
25 const suffixes = existing
26 .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 });
32
33 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'.

3

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.

server/routes/public-posts.js
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';
4
5export 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;
12
13 try {
14 // Build base query: published posts only
15 let whereConditions = [eq(posts.status, 'published')];
16
17 // Full-text search
18 if (searchQuery) {
19 whereConditions.push(
20 sql`to_tsvector('english', ${posts.title} || ' ' || ${posts.body}) @@ plainto_tsquery('english', ${searchQuery})`
21 );
22 }
23
24 // Filter by category slug
25 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 }
32
33 const condition = and(...whereConditions);
34 const [{ total }] = await db.select({ total: count() }).from(posts).where(condition);
35
36 const results = await db
37 .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);
49
50 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.

4

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.

server/routes/admin-posts.js
1import { db } from '../db.js';
2import { posts } from '../../shared/schema.js';
3import { eq } from 'drizzle-orm';
4
5// PATCH /api/admin/posts/:id/publish
6export async function publishPost(req, res) {
7 const { id } = req.params;
8 const [updated] = await db
9 .update(posts)
10 .set({ status: 'published', publishedAt: new Date(), updatedAt: new Date() })
11 .where(eq(posts.id, parseInt(id)))
12 .returning();
13
14 if (!updated) return res.status(404).json({ error: 'Post not found' });
15 res.json(updated);
16}
17
18// PATCH /api/admin/posts/:id/unpublish
19export async function unpublishPost(req, res) {
20 const { id } = req.params;
21 const [updated] = await db
22 .update(posts)
23 .set({ status: 'draft', updatedAt: new Date() })
24 .where(eq(posts.id, parseInt(id)))
25 .returning();
26
27 if (!updated) return res.status(404).json({ error: 'Post not found' });
28 res.json(updated);
29}
30
31// DELETE /api/admin/posts/:id — soft delete (archive)
32export async function archivePost(req, res) {
33 const { id } = req.params;
34 const [updated] = await db
35 .update(posts)
36 .set({ status: 'archived', updatedAt: new Date() })
37 .where(eq(posts.id, parseInt(id)))
38 .returning();
39
40 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

server/utils/slugify.js
1import { db } from '../db.js';
2import { posts } from '../../shared/schema.js';
3import { like } from 'drizzle-orm';
4
5export function slugify(text) {
6 return text
7 .toString()
8 .toLowerCase()
9 .trim()
10 .replace(/[^\w\s-]/g, '')
11 .replace(/[\s_-]+/g, '-')
12 .replace(/^-+|-+$/g, '');
13}
14
15export async function generateUniqueSlug(title, excludePostId = null) {
16 const base = slugify(title);
17 if (!base) throw new Error('Title produces an empty slug');
18
19 const existing = await db
20 .select({ slug: posts.slug })
21 .from(posts)
22 .where(like(posts.slug, `${base}%`));
23
24 const taken = existing
25 .map(r => r.slug)
26 .filter(s => !excludePostId); // allow reuse of own slug on update
27
28 if (!taken.includes(base)) return base;
29
30 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.

ChatGPT Prompt

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.

Build Prompt

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.

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.