How to Implement Follow and Unfollow Functionality in the RealWorld API
The RealWorld application implements follow and unfollow functionality through a self-referencing many-to-many relationship in Prisma, exposed via authenticated POST and DELETE endpoints that return a normalized Profile object with a boolean following flag.
The follow and unfollow functionality allows authenticated users to curate their personal article feed by subscribing to or removing specific authors. As implemented in the gothinkster/realworld monorepo, this feature spans the database schema, REST API, and frontend integration layers using a consistent contract across all framework implementations.
Database Schema Design
The foundation relies on a self-referencing many-to-many relation defined in the Prisma schema. In apps/api/prisma/schema.prisma, the User model declares two relation fields that reference each other:
model User {
id Int @id @default(autoincrement())
username String @unique
// …other fields…
// Self-referencing many-to-many relation:
followedBy User[] @relation("UserFollows")
following User[] @relation("UserFollows")
}
followedBycontains the users that follow this specific user.followingcontains the users this specific user follows.
Prisma automatically generates the junction table _UserFollows to manage this relationship, as seen in the migration files.
API Endpoints and Contract
The backend exposes two private endpoints under the /api/profiles/:username/follow route. Both require Bearer token authentication and return a standardized Profile payload.
| Method | Endpoint | Auth | Response |
|---|---|---|---|
POST |
/api/profiles/:username/follow |
Bearer token | { profile: { username, bio, image, following: true } } |
DELETE |
/api/profiles/:username/follow |
Bearer token | { profile: { username, bio, image, following: false } } |
These routes utilize the definePrivateEventHandler wrapper, which injects the authenticated user's ID into the request context.
Server-Side Implementation
Following a User (POST)
In apps/api/server/routes/api/profiles/[username]/follow/index.post.ts, the handler connects the current user to the target's followedBy relation:
import profileMapper from "~/utils/profile.utils";
import { definePrivateEventHandler } from "~/auth-event-handler";
export default definePrivateEventHandler(async (event, { auth }) => {
const username = getRouterParam(event, "username");
const profile = await usePrisma().user.update({
where: { username },
data: {
// Add the current user to the target's followedBy list
followedBy: { connect: { id: auth.id } },
},
include: { followedBy: true },
});
// Return a normalized profile payload
return { profile: profileMapper(profile, auth.id) };
});
Unfollowing a User (DELETE)
In apps/api/server/routes/api/profiles/[username]/follow/index.delete.ts, the handler disconnects the current user from the target's followedBy relation:
import profileMapper from "~/utils/profile.utils";
import { definePrivateEventHandler } from "~/auth-event-handler";
export default definePrivateEventHandler(async (event, { auth }) => {
const username = getRouterParam(event, "username");
const profile = await usePrisma().user.update({
where: { username },
data: {
// Remove the current user from the target's followedBy list
followedBy: { disconnect: { id: auth.id } },
},
include: { followedBy: true },
});
return { profile: profileMapper(profile, auth.id) };
});
Profile Mapping Utility
Both handlers rely on profileMapper in apps/api/server/utils/profile.utils.ts to compute the following boolean and transform the database record into the API response format:
import { User } from "~/models/user.model";
import { Profile } from "~/models/profile.model";
const profileMapper = (user: any, id: number | undefined): Profile => ({
username: user.username,
bio: user.bio,
image: user.image,
following: id
? user?.followedBy.some((followingUser: Partial<User>) => followingUser.id === id)
: false,
});
export default profileMapper;
The function checks if the authenticated user's ID exists within the target user's followedBy array, returning true if the follow relationship exists.
Frontend Integration
The client implementation toggles the follow state by calling the appropriate endpoint and updating the UI based on the response. A generic JavaScript implementation follows this pattern:
async function toggleFollow(username, currentlyFollowing) {
const method = currentlyFollowing ? "DELETE" : "POST";
const response = await fetch(`/api/profiles/${username}/follow`, {
method,
credentials: "include", // sends the auth cookie / token
headers: { "Content-Type": "application/json" },
});
const { profile } = await response.json();
// Update button text based on the new state
button.textContent = profile.following ? "Unfollow" : "Follow";
}
The expected UI selectors are documented in the end-to-end test helpers at specs/e2e/helpers/profile.ts:
export async function followUser(page: Page, username: string) {
await page.goto(`/profile/@${username}`);
await page.waitForSelector('button:has-text("Follow")', { timeout: 10000 });
await page.click('button:has-text("Follow")');
}
export async function unfollowUser(page: Page, username: string) {
await page.waitForSelector('button:has-text("Unfollow")', { timeout: 10000 });
await page.click('button:has-text("Unfollow")');
}
When the button displays Unfollow, the client has received { following: true } from the server; clicking it again invokes the DELETE endpoint and receives { following: false }.
Feed Integration
The follow and unfollow functionality directly impacts the "Your Feed" feature. In apps/api/server/routes/api/articles/feed.get.ts, the endpoint filters articles where the author's followedBy relation includes the authenticated user:
const articles = await usePrisma().article.findMany({
where: { author: { followedBy: { some: { id: auth.id } } } },
include: { author: { include: { followedBy: true } } },
});
Therefore, successful follow operations immediately populate the user's personalized feed with the followed author's articles, while unfollow operations remove them.
Summary
- Database Layer: Uses a self-referencing many-to-many Prisma relation (
followedBy/following) on theUsermodel defined inapps/api/prisma/schema.prisma. - API Layer: Provides authenticated
POSTandDELETEendpoints at/api/profiles/:username/followusingdefinePrivateEventHandler. - Business Logic: Leverages Prisma's
connectanddisconnectoperations to mutate relationships atomically. - Response Format: Returns a normalized
Profileobject viaprofileMapperinapps/api/server/utils/profile.utils.ts, including a computedfollowingboolean. - Frontend Contract: Expects UI implementations to toggle between "Follow" and "Unfollow" button states based on the API response.
- Feed Impact: The "Your Feed" endpoint filters articles by the
followedByrelation, ensuring real-time feed updates.
Frequently Asked Questions
How does the RealWorld API track which users follow each other?
The system tracks relationships through a self-referencing many-to-many relation in the Prisma schema. The User model contains two fields—followedBy and following—that both participate in the "UserFollows" relation. Prisma automatically manages the junction table _UserFollows behind the scenes, storing pairs of user IDs to represent each follow relationship.
What is the difference between the POST and DELETE follow endpoints?
The POST endpoint (/api/profiles/:username/follow) creates a follow relationship by using Prisma's connect operation to add the authenticated user to the target's followedBy list. The DELETE endpoint removes the relationship using Prisma's disconnect operation. Both endpoints return an identical Profile payload structure, differing only in the following boolean value (true for POST, false for DELETE).
How does the frontend know whether to show "Follow" or "Unfollow"?
The profileMapper utility in apps/api/server/utils/profile.utils.ts computes the following boolean by checking if the authenticated user's ID exists in the target user's followedBy array. The API includes this flag in every Profile response, allowing the frontend to render the appropriate button state without maintaining separate client-side logic.
Why do followed users appear in "Your Feed" immediately?
The feed endpoint in apps/api/server/routes/api/articles/feed.get.ts queries articles using a Prisma where clause that filters for authors whose followedBy relation contains the current user's ID. Because the follow endpoints modify this same relation using atomic connect and disconnect operations, the feed query reflects changes immediately without requiring additional caching or synchronization steps.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →