# How to Build a Social Media Feed with Replit

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

## TL;DR

Build a Twitter/Instagram-style social feed with Replit in 1-2 hours. You'll create an Express API with PostgreSQL (Drizzle ORM) for posts, followers, likes, and comments, plus a React frontend with a timeline, profile pages, and a post composer. Replit Agent scaffolds the full app from one prompt. Deploy on Autoscale.

## Before you start

- A Replit account (Free tier is sufficient)
- Basic understanding of what APIs and databases do (no coding experience needed)
- An idea of what content type your feed will display (text, images, or both)
- Optional: an image storage service like Cloudflare R2 if you want image uploads (API key stored in Secrets)

## Step-by-step guide

### 1. Scaffold the social feed app with Replit Agent

Open Replit, click Create Repl, and use the Agent prompt below. Agent will generate the full Express server, Drizzle schema for profiles, posts, follows, likes, and comments, plus a React frontend with a two-column layout. This single prompt gives you a working skeleton.

```
// Paste this into Replit Agent:
// Build a social media feed app with Express and PostgreSQL using Drizzle ORM.
// Schema: profiles (id, user_id unique, username unique, display_name, bio, avatar_url, follower_count default 0, following_count default 0, created_at),
// posts (id, author_id references profiles, content, image_url, like_count default 0, comment_count default 0, created_at),
// follows (id, follower_id references profiles, following_id references profiles, created_at, UNIQUE follower_id+following_id),
// likes (id, post_id references posts, user_id references profiles, created_at, UNIQUE post_id+user_id),
// comments (id, post_id references posts, author_id references profiles, content, created_at).
// Add a compound index on posts(author_id, created_at DESC).
// API routes: GET /api/feed (timeline from followed users, cursor pagination by post ID),
// GET /api/feed/explore (all recent posts), POST /api/posts, DELETE /api/posts/:id,
// POST /api/posts/:id/like (toggle), GET /api/posts/:id/comments, POST /api/posts/:id/comments,
// GET /api/profiles/:username, POST /api/profiles/:id/follow (toggle),
// GET /api/profiles/:id/followers, GET /api/profiles/:id/following.
// On first Replit Auth login, auto-create a profile with a generated username.
// React frontend: two-column layout with main feed and suggested profiles sidebar,
// post cards with avatar, username, timestamp, content, like button with count, comment icon with count,
// post composer at the top of feed, profile pages with follower/following counts and post grid.
// Bind server to 0.0.0.0. Use process.env.DATABASE_URL for DB connection.
```

> Pro tip: If you want image uploads, add 'image upload button that stores to Cloudflare R2 using a pre-signed URL from the server' to the prompt. Store the R2 credentials in Replit Secrets (lock icon) before running the app.

**Expected result:** Agent creates the full project structure with server/index.js, server/schema.js, server/routes/, and a React frontend. The preview shows a working feed UI.

### 2. Review and push the Drizzle schema

After Agent finishes, open the Database tool in Replit's sidebar to verify the tables were created. If they weren't, you'll push the schema manually. Also review the schema file to ensure the unique constraints on follows and likes are present — these prevent duplicate follows and double-likes at the database level.

```
// server/schema.js — key parts to verify
const { pgTable, serial, text, integer, timestamp, uniqueIndex, index } = require('drizzle-orm/pg-core');

const profiles = pgTable('profiles', {
  id: serial('id').primaryKey(),
  userId: text('user_id').notNull().unique(),
  username: text('username').notNull().unique(),
  displayName: text('display_name'),
  bio: text('bio'),
  avatarUrl: text('avatar_url'),
  followerCount: integer('follower_count').default(0).notNull(),
  followingCount: integer('following_count').default(0).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  authorId: integer('author_id').references(() => profiles.id).notNull(),
  content: text('content').notNull(),
  imageUrl: text('image_url'),
  likeCount: integer('like_count').default(0).notNull(),
  commentCount: integer('comment_count').default(0).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
}, (t) => [
  index('posts_author_created_idx').on(t.authorId, t.createdAt),
]);

const follows = pgTable('follows', {
  id: serial('id').primaryKey(),
  followerId: integer('follower_id').references(() => profiles.id).notNull(),
  followingId: integer('following_id').references(() => profiles.id).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
}, (t) => [
  uniqueIndex('follows_pair_idx').on(t.followerId, t.followingId),
]);
```

> Pro tip: Open Drizzle Studio from the Database tool to visually inspect your tables and run test queries before writing any frontend code.

### 3. Implement the timeline feed route with cursor pagination

The feed route is the performance-critical path. It queries posts from users the viewer follows, ordered by recency. Cursor-based pagination using the post ID prevents the 'page drift' problem where rows shift between pages. Replace any simple LIMIT/OFFSET implementation Agent generated with the cursor approach below.

```
// server/routes/feed.js
const express = require('express');
const { desc, lt, inArray, eq } = require('drizzle-orm');
const { db } = require('../db');
const { posts, profiles, follows } = require('../schema');

const router = express.Router();

router.get('/api/feed', async (req, res) => {
  const userId = req.user?.id;  // from Replit Auth middleware
  const cursor = req.query.cursor ? parseInt(req.query.cursor) : null;
  const limit = 20;

  // Get IDs of users this person follows
  const followedRows = await db.select({ followingId: follows.followingId })
    .from(follows)
    .where(eq(follows.followerId, userId));

  const followedIds = followedRows.map(r => r.followingId);
  if (followedIds.length === 0) {
    return res.json({ posts: [], nextCursor: null });
  }

  // Build query with optional cursor
  let query = db.select()
    .from(posts)
    .where(inArray(posts.authorId, followedIds));

  if (cursor) {
    query = query.where(lt(posts.id, cursor));
  }

  const results = await query
    .orderBy(desc(posts.createdAt))
    .limit(limit + 1);

  const hasMore = results.length > limit;
  const paginated = hasMore ? results.slice(0, limit) : results;
  const nextCursor = hasMore ? paginated[paginated.length - 1].id : null;

  res.json({ posts: paginated, nextCursor });
});

module.exports = router;
```

> Pro tip: If a user follows nobody, show the explore feed instead as a fallback. This prevents new users from seeing a blank feed.

### 4. Add the like toggle route with trigger-maintained counts

The like system uses a toggle pattern — one endpoint handles both liking and unliking. Rather than counting likes on every read, a PostgreSQL trigger maintains the `like_count` field on the posts table. This keeps read queries fast even at scale. Ask Agent to add the trigger, or use the SQL below.

```
// server/routes/likes.js
const express = require('express');
const { and, eq } = require('drizzle-orm');
const { db } = require('../db');
const { likes, posts } = require('../schema');

const router = express.Router();

router.post('/api/posts/:id/like', async (req, res) => {
  const postId = parseInt(req.params.id);
  const userId = req.user.profileId;  // numeric profile ID from middleware

  // Check if like already exists
  const existing = await db.select()
    .from(likes)
    .where(and(eq(likes.postId, postId), eq(likes.userId, userId)))
    .limit(1);

  if (existing.length > 0) {
    // Unlike
    await db.delete(likes)
      .where(and(eq(likes.postId, postId), eq(likes.userId, userId)));
    await db.update(posts)
      .set({ likeCount: db.sql`like_count - 1` })
      .where(eq(posts.id, postId));
    return res.json({ liked: false });
  }

  // Like
  await db.insert(likes).values({ postId, userId });
  await db.update(posts)
    .set({ likeCount: db.sql`like_count + 1` })
    .where(eq(posts.id, postId));

  res.json({ liked: true });
});

module.exports = router;
```

**Expected result:** Clicking the heart on a post increments or decrements the count immediately and persists across page refreshes.

### 5. Deploy on Autoscale and test the feed

Social feeds have variable traffic — quiet overnight, spiky when someone shares a post publicly. Autoscale handles this perfectly, scaling to zero when no one's active and spinning up new instances during traffic bursts. Deploy from the Publish pane, then create a few test accounts to verify the follow/feed flow end to end.

```
// .replit — verify deployment config
// entrypoint = "server/index.js"
// [deployment]
// deploymentTarget = "autoscale"
// run = "node server/index.js"
//
// Also confirm server/index.js binds correctly:
const PORT = process.env.PORT || 3000;
app.listen(PORT, '0.0.0.0', () => {
  console.log(`Social feed server running on port ${PORT}`);
});
```

> Pro tip: After deploying, log in with two different browser sessions (or incognito) to test the follow relationship. Follow User A from User B, create a post as User A, then verify it appears in User B's timeline.

**Expected result:** The app is live at your *.replit.app URL. The timeline loads posts from followed users, likes persist, and profiles show accurate follower counts.

## Complete code example

File: `server/routes/feed.js`

```javascript
const express = require('express');
const { desc, lt, inArray, eq, and } = require('drizzle-orm');
const { db } = require('../db');
const { posts, profiles, follows, likes, comments } = require('../schema');

const router = express.Router();

// GET /api/feed — timeline from followed users (cursor paginated)
router.get('/api/feed', async (req, res) => {
  const userId = req.user?.profileId;
  const cursor = req.query.cursor ? parseInt(req.query.cursor) : null;
  const limit = 20;

  const followedRows = await db
    .select({ followingId: follows.followingId })
    .from(follows)
    .where(eq(follows.followerId, userId));

  const followedIds = followedRows.map(r => r.followingId);

  if (followedIds.length === 0) {
    return res.json({ posts: [], nextCursor: null, empty: true });
  }

  const conditions = [inArray(posts.authorId, followedIds)];
  if (cursor) conditions.push(lt(posts.id, cursor));

  const results = await db
    .select({
      id: posts.id,
      content: posts.content,
      imageUrl: posts.imageUrl,
      likeCount: posts.likeCount,
      commentCount: posts.commentCount,
      createdAt: posts.createdAt,
      authorUsername: profiles.username,
      authorDisplayName: profiles.displayName,
      authorAvatarUrl: profiles.avatarUrl,
    })
    .from(posts)
    .innerJoin(profiles, eq(posts.authorId, profiles.id))
    .where(and(...conditions))
    .orderBy(desc(posts.createdAt))
    .limit(limit + 1);

  const hasMore = results.length > limit;
  const paginated = hasMore ? results.slice(0, limit) : results;

  res.json({
    posts: paginated,
    nextCursor: hasMore ? paginated[paginated.length - 1].id : null,
  });
});

// GET /api/feed/explore — all recent posts for discovery
router.get('/api/feed/explore', async (req, res) => {
  const cursor = req.query.cursor ? parseInt(req.query.cursor) : null;
  const conditions = cursor ? [lt(posts.id, cursor)] : [];

  const results = await db
module.exports = router;
```

## Common mistakes

- **Feed query is slow with OFFSET pagination** — OFFSET pagination rescans all skipped rows on each page, which gets slower the deeper a user scrolls. Beginners use it because it's simple. Fix: Use cursor pagination: pass the last seen post's ID as a cursor, and add `WHERE id < :cursor` to the query. This keeps page 10 as fast as page 1.
- **Like counts drift out of sync with the actual likes table** — When you update the count in application code, a failed request or race condition can leave the count wrong. The count on the post row no longer matches the actual rows in the likes table. Fix: Either use a PostgreSQL trigger to maintain the count, or always compute `SELECT COUNT(*) FROM likes WHERE post_id = ?` and avoid the denormalized column entirely for lower-traffic apps.
- **Auto-created profile fails silently on first login** — If the profile creation middleware throws an error (e.g., username collision on the unique constraint), the user is logged in but has no profile, causing 500 errors on every feed request. Fix: Wrap the auto-create logic in a try/catch and use an `ON CONFLICT DO NOTHING` insert, then always query for the existing profile after the insert attempt.

## Best practices

- Store image upload credentials (Cloudflare R2, AWS S3) in Replit Secrets (lock icon), never hardcoded in source files
- Use the compound index on `(author_id, created_at DESC)` — without it the timeline query does a full table scan as posts grow
- Keep like and follow counts denormalized on the parent row for fast reads, but update them in the same transaction as the like/follow insert
- Validate post content length server-side (e.g., max 280 characters) — never trust client-side validation alone
- Add the retry wrapper from `server/lib/retryDb.js` to the feed route — after 5 minutes idle, the first query reconnects to PostgreSQL, causing a 1-2 second delay
- Use Drizzle Studio (open via the Database tool in Replit) to inspect query results and test indexes without writing extra code
- Rate-limit the post creation route to prevent spam — a simple in-memory counter per user_id with a 1-minute window is enough for early-stage apps

## Frequently asked questions

### Can I build a social feed without any coding experience using Replit?

Yes. Replit Agent generates the entire Express + PostgreSQL backend and React frontend from the prompt in step 1. You'll need to follow along with the steps to verify the schema and test the routes, but you don't need to write code from scratch.

### How many users can my social feed handle on the free Replit plan?

The free plan's Autoscale deployment handles moderate traffic well — easily hundreds of concurrent users. The main limits are PostgreSQL storage (10 GB) and compute. For a community app with tens of thousands of active users, upgrade to Replit Core for more resources.

### Why is my timeline slow when a user follows many people?

The timeline query does an IN clause over all followed user IDs. At 500+ follows, this can be slow without proper indexing. Make sure the compound index on `(author_id, created_at DESC)` exists. For very high follow counts (10K+), consider a fan-out-on-write architecture instead.

### Should I use Autoscale or Reserved VM for a social feed?

Autoscale is the right choice for most social feeds. Traffic is variable — quiet overnight and spiky when content goes viral. Autoscale scales to zero when idle (saving cost) and spins up fast enough that the cold start is masked by network latency. Reserved VM ($6-20/month) makes sense only if you add WebSocket-based real-time features.

### How do I add image uploads to posts?

The recommended pattern is a pre-signed URL flow: the client asks your Express API for a pre-signed upload URL from Cloudflare R2 or AWS S3, uploads directly from the browser to the storage provider, then sends the resulting URL to your API to attach to the post. Store the R2 or S3 credentials in Replit Secrets (lock icon in sidebar).

### How do I prevent one user from following themselves?

Add a server-side check in the follow route: `if (follower_id === following_id) return res.status(400).json({ error: 'Cannot follow yourself' })`. The unique constraint handles duplicate follows but not self-follows.

### Can RapidDev help me build a custom social feed?

Yes. RapidDev has built 600+ apps including custom social and community platforms. If you need advanced features like real-time notifications, content moderation, or a recommendation algorithm, book a free consultation at rapidevelopers.com.

### Why does my feed return an empty array for new users?

New users follow nobody, so the timeline query returns zero posts. Show the explore feed instead as a fallback: if the follows count is zero, redirect to `GET /api/feed/explore`. You can also implement suggested follows based on popular profiles to help new users get started.

---

Source: https://www.rapidevelopers.com/how-to-build-replit/social-media-feed
© RapidDev — https://www.rapidevelopers.com/how-to-build-replit/social-media-feed
