DEV Community

Cover image for CampusZen
Ayush
Ayush

Posted on

CampusZen

CampusZen is live: a student-first social network built with Next.js 16, Express, and MongoDB

CampusZen — campus life, in one live feed

Every campus already has a social network. It's spread across eleven WhatsApp groups, a few Instagram accounts, a Notion page nobody updates, and one senior who knows everything.

None of those places are searchable by college, course, or year. You can't follow a person — you get added to a group. You can't find the second-year who's building a compiler for fun, or the design club that actually ships.

General social networks optimize for reach. A campus feed should optimize for context: the same college, the same course, the same year, the same week of exams.

CampusZen is my attempt to build that place. It's a text-first social network scoped to students, built around one loop:

Sign up → discover students → follow → post → interact → return

I'm not trying to beat X. I'm trying to make a smaller, safer place where the graph starts with your campus, and where the feature set stops at the loop instead of growing into an infinite feed of everything.

It's live at campuszen.tech, the code is open source under AGPL-3.0, and this is the launch write-up: what shipped, how it's built, and the decisions that mattered.

TL;DR: CampusZen is a student-first social network with short posts, 50k-character Markdown articles, polls, realtime notifications, author analytics, and merit verification — built on Next.js 16, Express, MongoDB, and Bun. It's live, and the code is AGPL-3.0.

What's live

The product is deliberately narrow. No DMs, no stories, no clips, no marketplace, no recommendation black box. The initial focus is Indian campuses, but the data model isn't tied to one country.

Identity and graph. Profiles carry college, course, year, avatar, cover, bio, social links, and a pinned post. Follow/unfollow, follower lists, suggestions, private accounts with follow requests, and student/college directories.

Short-form posts. 500 characters, up to four photos/GIFs/60-second video attachments, or a 2–4 option poll. Likes, replies, reposts, bookmarks, views, hashtags, and @mentions extracted on the server.

Long-form articles. Up to 50,000 characters of Markdown at /app/a/{username}/{slug}, with ${username/slug} embeds, code blocks, tables, and edit support. This is the feature I use most.

Discovery. Following and For You feeds, an Interest Hub with six topics, hashtag feeds, trending, and full-text search across students, posts, and colleges.

Realtime. In-app notifications over SSE, Web Push when the tab is closed, unread counts, and live count patches without refetching feeds.

Creator tools. 24h/7d analytics with deduped impressions, top posts by views, merit verification (50 posts or 100 followers, admin-reviewed), and share cards generated on demand with next/og at 1200×630 and 1080×1920.

Safety. Block/report/appeals, an admin review queue, private accounts, 18+ media blur with per-session reveal, rate limits, and server-side validation.

The newest pieces in this launch are articles, analytics, verification, share cards, the Interest Hub, and the anonymous hub.

The stack

Layer Choice
Frontend Next.js 16.3 (App Router), React 19, Tailwind CSS v4, TanStack Query, Framer Motion, Biome
Backend Node.js ESM, Express 4, Zod, Mongoose 8
Data MongoDB, Appwrite Storage for media, in-memory cache
Auth JWT access + refresh cookies, rotating refresh tokens, OTP email
Realtime Server-Sent Events + Web Push (VAPID)
Tooling Bun 1.4.2, two independent apps, no workspace root

Two apps, one repo. Next.js serves the UI; the browser calls Express directly with cookies, and Express owns the database. There's no API proxy — one less hop, but it means CORS and cookie settings have to be right.

Browser
  ├─▶ Next.js 16 (:3000) — pages, RSC, share cards
  └─▶ fetch(credentials: "include")
        └─▶ Express 4 (:4000) — JSON API
              ├── MongoDB (Mongoose)
              ├── Appwrite Storage (media)
              ├── Gmail SMTP (OTP)
              ├── SSE /api/events ──▶ Browser
              └── Web Push / VAPID ──▶ Service worker
Enter fullscreen mode Exit fullscreen mode

By the numbers: about 35k lines across 251 JS/JSX/CSS files, 18 Mongoose models, 14 services, and 12 route modules. The product docs live beside the code — PRD.md, DESIGN.md, and a full docs.md developer guide.

Five decisions that mattered

1. A randomized feed that still paginates correctly

The Following feed is straightforward: newest first, keyset pagination on (createdAt, _id). The For You feed is where naive implementations break.

A random feed plus offset pagination is a bug factory. Every refresh reshuffles, so page 2 overlaps page 1 or skips posts entirely. $sample on every request is expensive. A cache miss can break an in-flight scroll session.

The fix was to make randomness deterministic per session. A 12-character hex seed is created once. Every candidate post gets a rank from a 32-bit FNV-1a hash:

export function orderIds(seed, ids) {
  const scored = ids.map((id) => ({
    id,
    rank: fnv1a(`${seed}:${id}`),
  }));

  return scored
    .sort((a, b) => a.rank - b.rank || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
    .map((s) => s.id);
}
Enter fullscreen mode Exit fullscreen mode

The shuffled ID list is memoized for ten minutes. Page 2+ is a RAM slice plus (usually) a single $in hydration — no re-sorting, no re-sampling, no cursor scan. Candidates are capped at 20,000 so a small instance can't sort the entire database.

The cursor is an opaque base64url token:

// decoded shape
{ s: "9f2c1a7b3d40", k: 40, l: "665f1c..." }
// s = session seed, k = next index, l = last post actually shown
Enter fullscreen mode Exit fullscreen mode

The lastId anchor is the important part. If the order list is rebuilt mid-session because new posts arrived, the server resumes after the last post the user saw instead of trusting a stale index. No duplicates, and at worst a few new posts appear between pages.

The Following feed uses the same wire format with a different shape: { c, i }, where c is the last createdAt and i is the tie-break _id. One cursor contract, two behaviors.

2. SSE for notifications, Web Push for when the tab is gone

Notifications are one-way. WebSockets would work, but SSE is a better fit: it's HTTP, EventSource reconnects on its own, and I don't need a second protocol or a client library.

Three details made it production-shaped:

  • Multi-tab: Map<userId, Set<response>>, so one account can have the app open on a laptop and a phone.
  • Replay: the last 100 events per user are buffered in memory. On reconnect, the browser sends Last-Event-ID and the server replays what was missed.
  • Heartbeat: a comment every 25 seconds keeps proxies from closing an idle stream. X-Accel-Buffering: no stops nginx from buffering it.
const sseClients = new Map();     // userId -> Set<res>
const replayBuffers = new Map();  // userId -> last 100 events

function writeEvent(res, id, event, data) {
  res.write(`id: ${id}\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
}
Enter fullscreen mode Exit fullscreen mode

Auth was the annoying part. EventSource can't set an Authorization header, so the endpoint accepts the access token from a cookie, a ?token= query parameter, or a Bearer header — whichever the client can provide.

SSE can't reach a closed tab, so Web Push covers that case. VAPID subscriptions live in MongoDB; the service worker shows the notification and focuses an existing app tab on click. The same service worker keeps a 60-minute runtime cache for cross-origin post images and avatars, which cuts repeat CDN round-trips on mobile.

The honest caveat: clients and replay buffers are in memory. That's fine on one instance and wrong behind a load balancer. Redis pub/sub is the upgrade path.

3. Auth that doesn't lose the session every 15 minutes

Both tokens are httpOnly cookies: a 15-minute access token and a refresh token valid for a day (seven with "remember me"). Neither token is exposed to JavaScript. The refresh token is stored in Mongo only as a SHA-256 hash and rotated on every refresh.

The client wrapper has two jobs: retry the original request after a refresh, and never fire ten refreshes when ten components get a 401 at once.

// Single-flight refresh: concurrent 401s share one rotation.
let refreshPromise = null;

function refreshSession() {
  if (!refreshPromise) {
    refreshPromise = (async () => {
      try {
        await request("/api/auth/refresh", {
          method: "POST",
          _skipRetry: true,
        });
      } finally {
        refreshPromise = null;
      }
    })();
  }
  return refreshPromise;
}
Enter fullscreen mode Exit fullscreen mode

Auth routes are excluded from auto-retry — a 401 on login means bad credentials, not an expired session. /auth/me is included, with a _retried flag so a failed refresh can't loop.

Cookies are SameSite=lax in development and SameSite=none + Secure when the frontend and API are on different sites. trust proxy is set so Express sees the real protocol behind a proxy.

Nothing here is novel. It's the minimum needed to make cookie auth feel invisible.

4. Counts that don't drift, and a UI that feels instant

Social apps are mostly counters. A like changes the post count, the viewer's liked state, the author's notification, the analytics total, and every cached copy of that post across feeds, bookmarks, and profile tabs.

Three rules kept it sane:

  1. Counters are denormalized on User and Post, while the source rows still exist.
  2. Duplicates are impossible at the database level. Unique indexes on (user, post) for likes, bookmarks, reposts, and poll votes; (follower, following) for follows. The insert is the arbiter, not a read-then-write check.
  3. Optimistic updates go through shared cache patchers. patchPostEverywhere and patchUserEverywhere are called by local mutations and SSE events alike, so the feed, detail view, and profile tabs can't disagree.

On the server, cache invalidation is deliberately narrow. A like should not call delPattern("feed:*"). With a hundred people liking at once, every like becomes a stampede of feed rebuilds. Feed pages have a 30-second TTL; seeded order lists live for ten minutes; writes invalidate the surfaces they actually affect.

The rule I keep coming back to: the database is the source of truth, the server cache is a short-lived projection, and the client cache is another projection. Give each projection exactly one owner.

5. Two content types, two threat models

Short posts and long articles are not the same problem.

A post is capped at 500 characters, four attachments, and an optional poll. Media goes to Appwrite; the Post document stores metadata and a legacy imageUrl mirror so older clients keep rendering.

An article is up to 50,000 characters of Markdown with a title, description, slug, and ${username/slug} embeds. It gets its own route and its own body-parser limit: 1 MB for /api/posts/articles, 10 KB everywhere else. Express marks a request as parsed, so the big parser runs first and the strict one skips it.

Markdown is rendered with marked (GFM + line breaks) and then sanitized: scripts, iframes, event handlers, and javascript: URLs are stripped before dangerouslySetInnerHTML. That's not a perfect sanitizer, but it's a deliberate second line of defense after the body limit and before the browser.

The anonymous hub is the other extreme. The real author is stored for moderation but masked in every API response. Aliases are deterministic per thread — hash(postId:userId) — so the same person keeps the same alias inside a conversation but can't be correlated across threads. Anonymous posts never enter profiles, search, or the main feeds; they only exist in the anonymous hub, and they support likes, replies, and views only — no reposts, bookmarks, or polls.

A feature with a different privacy model gets a different serialization path, not a boolean check in the UI.

Safety and design are product features

Security is mostly defaults applied consistently:

  • Strict CSP on the API (default-src 'none'), CORS locked to the frontend origin, helmet, hpp, and express-mongo-sanitize.
  • Zod validation on writes and one error shape: { success, message, code, details }.
  • Per-route rate limits: signup 5/hour, login 10/minute, password reset 5/hour, refresh 20/minute, reports 20/hour.
  • A 10 KB global body cap; 1 MB only for articles.
  • OTPs hashed with a 10-minute TTL, five attempts, and a 30-second resend throttle.
  • Private accounts with follow requests, mutual-hide blocks that auto-unfollow, reports with instant local hide and an appeal path, and an env-gated admin dashboard for reports, verifications, suspensions, and broadcasts.
  • 18+ media stays blurred until the viewer opts in; the reveal is per session.

The design is intentionally familiar: a 990px shell, a collapsed 68px icon rail on desktop, a bottom tab bar on mobile, hairline dividers instead of card shadows, and exactly one chromatic accent — #1d9bf0 — on things you can tap. Like is pink, repost is green, errors are red; those are semantic states, not decoration. Inter with ss01, 15/20 body text.

Dark mode is a cookie read in the root server layout and stamped on <html>, so the first paint is already correct — no flash and no client script.

What I'd do differently (and what I'd keep)

Write tests earlier. There is no automated test suite yet. For an MVP built to validate a loop, manual testing plus strict validation got it shipped, but this is the biggest gap in the repo. The next meaningful commit is a Playwright suite around signup → post → follow → notification, plus Vitest/Supertest coverage for auth, feed cursors, and poll expiry.

Design for one instance, but leave a seam for many. In-memory cache and SSE state are honest free-tier choices. They also make the backend single-instance by construction. Before horizontal scaling, I'd move the cache and SSE fan-out to Redis. I'd rather name that now than pretend it scales.

"For You" is random, not ranked. A deterministic shuffle is cheap, explainable, and gives every post a chance. It is not a recommendation system. Ranking on recency, affinity, and engagement — with guardrails so new accounts aren't buried — is the next product problem, not a model to bolt on.

The signup gate is too blunt. Limiting signup to Gmail and Proton addresses kept spam out for launch, but it excludes students who use college email or Outlook. Campus-email verification, or a manual review queue, is the right long-term gate.

Scope is a feature. The list of things CampusZen doesn't do is longer than the list of things it does. That was deliberate. Every "no" protected the core loop.

What's next

  • Automated tests around the core loop.
  • Feed ranking that stays explainable.
  • Campus verification and richer college pages.
  • Communities — college groups, roles, and feeds.
  • DMs, clips, and events only after the core loop is strong.

Try it

CampusZen isn't trying to replace the group chat. It's trying to give the campus a front door: a place where you can find people, follow their work, and post something worth replying to.

If you're building something similar, steal the feed cursor trick. If you're a student, make an account and tell me what's missing. If you find a bug, open an issue — I'll be in the mentions.

Top comments (0)