# How to Build a Blog Backend with Replit

- Tool: How to Build with Replit
- Difficulty: Beginner
- Compatibility: Replit Free
- Last updated: April 2026

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

## Before you start

- 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

## Step-by-step guide

### 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 to type into Replit Agent:
// Build a Node.js Express headless blog backend with Replit Auth and built-in PostgreSQL using Drizzle ORM.
// Schema in shared/schema.ts:
// * authors: id serial pk, user_id text not null unique, name text not null,
//   bio text, avatar_url text, created_at timestamp default now()
// * posts: id serial pk, author_id integer references authors not null,
//   title text not null, slug text not null unique, excerpt text,
//   body text not null, cover_image_url text, status text default 'draft',
//   published_at timestamp, created_at timestamp default now(), updated_at timestamp default now()
// * categories: id serial pk, name text not null unique, slug text not null unique, description text
// * post_categories: id serial pk, post_id integer references posts not null,
//   category_id integer references categories not null, unique on (post_id, category_id)
// * tags: id serial pk, name text not null unique, slug text not null unique
// * post_tags: id serial pk, post_id integer references posts not null,
//   tag_id integer references tags not null, unique on (post_id, tag_id)
// Public routes (no auth): GET /api/posts, GET /api/posts/:slug, GET /api/categories, GET /api/tags
// Admin routes (Replit Auth): POST/PUT /api/admin/posts, PATCH /api/admin/posts/:id/publish,
// DELETE /api/admin/posts/:id, POST /api/admin/categories, POST /api/admin/tags
// 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.

```
import { db } from '../db.js';
import { posts } from '../../shared/schema.js';
import { like, sql } from 'drizzle-orm';

export function slugify(text) {
  return text
    .toLowerCase()
    .replace(/[^\w\s-]/g, '') // remove non-word chars
    .replace(/[\s_-]+/g, '-') // replace spaces/underscores with hyphens
    .replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens
}

export async function generateUniqueSlug(title) {
  const baseSlug = slugify(title);

  // Check for existing slugs that start with baseSlug
  const existing = await db
    .select({ slug: posts.slug })
    .from(posts)
    .where(like(posts.slug, `${baseSlug}%`));

  if (existing.length === 0) return baseSlug;

  // Find the highest suffix number
  const suffixes = existing
    .map(r => r.slug)
    .filter(s => s === baseSlug || s.match(new RegExp(`^${baseSlug}-\\d+$`)))
    .map(s => {
      if (s === baseSlug) return 0;
      return parseInt(s.replace(`${baseSlug}-`, '')) || 0;
    });

  const maxSuffix = Math.max(...suffixes);
  return `${baseSlug}-${maxSuffix + 1}`;
}
```

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

```
import { db } from '../db.js';
import { posts, authors, categories, postCategories, tags, postTags } from '../../shared/schema.js';
import { eq, and, inArray, desc, count, sql } from 'drizzle-orm';

export async function listPublishedPosts(req, res) {
  const page = Math.max(1, parseInt(req.query.page) || 1);
  const limit = Math.min(50, parseInt(req.query.limit) || 10);
  const offset = (page - 1) * limit;
  const categorySlug = req.query.category;
  const tagSlug = req.query.tag;
  const searchQuery = req.query.q;

  try {
    // Build base query: published posts only
    let whereConditions = [eq(posts.status, 'published')];

    // Full-text search
    if (searchQuery) {
      whereConditions.push(
        sql`to_tsvector('english', ${posts.title} || ' ' || ${posts.body}) @@ plainto_tsquery('english', ${searchQuery})`
      );
    }

    // Filter by category slug
    if (categorySlug) {
      const [cat] = await db.select({ id: categories.id }).from(categories).where(eq(categories.slug, categorySlug));
      if (cat) {
        const postIds = await db.select({ postId: postCategories.postId }).from(postCategories).where(eq(postCategories.categoryId, cat.id));
        whereConditions.push(inArray(posts.id, postIds.map(r => r.postId)));
      }
    }

    const condition = and(...whereConditions);
    const [{ total }] = await db.select({ total: count() }).from(posts).where(condition);

    const results = await db
      .select({
        id: posts.id, title: posts.title, slug: posts.slug,
        excerpt: posts.excerpt, coverImageUrl: posts.coverImageUrl,
        publishedAt: posts.publishedAt,
        authorName: authors.name, authorAvatarUrl: authors.avatarUrl,
      })
      .from(posts)
      .leftJoin(authors, eq(posts.authorId, authors.id))
      .where(condition)
      .orderBy(desc(posts.publishedAt))
      .limit(limit)
      .offset(offset);

    res.json({
      data: results,
      pagination: { page, limit, total: Number(total), totalPages: Math.ceil(Number(total) / limit) },
    });
  } catch (err) {
    res.status(500).json({ error: 'Failed to fetch posts' });
  }
}
```

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

```
import { db } from '../db.js';
import { posts } from '../../shared/schema.js';
import { eq } from 'drizzle-orm';

// PATCH /api/admin/posts/:id/publish
export async function publishPost(req, res) {
  const { id } = req.params;
  const [updated] = await db
    .update(posts)
    .set({ status: 'published', publishedAt: new Date(), updatedAt: new Date() })
    .where(eq(posts.id, parseInt(id)))
    .returning();

  if (!updated) return res.status(404).json({ error: 'Post not found' });
  res.json(updated);
}

// PATCH /api/admin/posts/:id/unpublish
export async function unpublishPost(req, res) {
  const { id } = req.params;
  const [updated] = await db
    .update(posts)
    .set({ status: 'draft', updatedAt: new Date() })
    .where(eq(posts.id, parseInt(id)))
    .returning();

  if (!updated) return res.status(404).json({ error: 'Post not found' });
  res.json(updated);
}

// DELETE /api/admin/posts/:id — soft delete (archive)
export async function archivePost(req, res) {
  const { id } = req.params;
  const [updated] = await db
    .update(posts)
    .set({ status: 'archived', updatedAt: new Date() })
    .where(eq(posts.id, parseInt(id)))
    .returning();

  if (!updated) return res.status(404).json({ error: 'Post not found' });
  res.json({ success: true, post: updated });
}
```

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

File: `server/utils/slugify.js`

```javascript
import { db } from '../db.js';
import { posts } from '../../shared/schema.js';
import { like } from 'drizzle-orm';

export function slugify(text) {
  return text
    .toString()
    .toLowerCase()
    .trim()
    .replace(/[^\w\s-]/g, '')
    .replace(/[\s_-]+/g, '-')
    .replace(/^-+|-+$/g, '');
}

export async function generateUniqueSlug(title, excludePostId = null) {
  const base = slugify(title);
  if (!base) throw new Error('Title produces an empty slug');

  const existing = await db
    .select({ slug: posts.slug })
    .from(posts)
    .where(like(posts.slug, `${base}%`));

  const taken = existing
    .map(r => r.slug)
    .filter(s => !excludePostId); // allow reuse of own slug on update

  if (!taken.includes(base)) return base;

  let counter = 2;
  while (taken.includes(`${base}-${counter}`)) counter++;
  return `${base}-${counter}`;
}
```

## Common mistakes

- **Returning draft posts from the public endpoint** — Without a WHERE status = 'published' filter, draft posts appear in public API responses and your unpublished writing becomes public. Fix: Always add eq(posts.status, 'published') to public list and detail queries. Only admin routes should query drafts.
- **N+1 queries when fetching posts with categories and tags** — Fetching 20 posts then making 20 separate queries for each post's categories results in 21 database round trips. This slows down as your post count grows. Fix: 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.
- **Changing the slug when updating a post title** — If you regenerate the slug whenever a post is edited, all existing links to that post (shared on social media, bookmarked) break permanently. Fix: 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.

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

---

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