DEV Community

Cover image for How I Built a Full Stack Community Platform from Scratch - And What I Learned
Samwit Adhikary
Samwit Adhikary

Posted on

How I Built a Full Stack Community Platform from Scratch - And What I Learned

A real story of building AskLoop - combining Medium + Dev.to + Stack Overflow with Django 5 + React 18


The Problem I Kept Seeing

I've spent a lot of time on developer platforms. And I kept noticing the same pattern everywhere.

You ask a question - it gets buried.
You share something valuable - it disappears in 48 hours.
A great discussion starts - nobody follows up.

There's always content. But rarely real depth.
There's always activity. But rarely meaningful interaction.

That bothered me enough to build something about it.

So I built AskLoop - a community platform that combines:

  • ✍️ Medium-style long-form articles
  • ❓ Stack Overflow-style Q&A with voting
  • πŸ’¬ Dev.to-style forum discussions

All in one place. No noise. No paywalls.

Live demo: https://askloop-here.netlify.app


What I Built

Before diving into the technical stuff, here's what the platform does:

  • 3 post types - Article, Q&A, Forum Discussion
  • Rich text editor with image upload (TipTap)
  • Nested comments with threaded replies
  • Q&A voting - upvote/downvote + accept answer
  • JWT Authentication - register, login, refresh tokens
  • User profiles - bio, social links, reputation
  • Badge system - auto-awarded at reputation milestones
  • Real-time notifications - likes, comments, follows
  • Full-text search with filters
  • Bookmarks, follows, reports
  • Account deletion with password confirmation

Tech Stack

I wanted to use tools I could actually understand and explain - not just copy-paste a boilerplate.

Backend

Django 5.0.6
Django REST Framework 3.15.2
Simple JWT (djangorestframework-simplejwt)
django-allauth + dj-rest-auth
PostgreSQL via Supabase
Gunicorn + Nginx
Whitenoise
Enter fullscreen mode Exit fullscreen mode

Frontend

React 18 + TypeScript
Vite
TipTap (rich text editor)
shadcn/ui + Tailwind CSS
Axios + React Router v6
date-fns
Enter fullscreen mode Exit fullscreen mode

Infrastructure

Backend β†’ Linode (Ubuntu 22.04)
Frontend β†’ Netlify
Database β†’ Supabase PostgreSQL
Enter fullscreen mode Exit fullscreen mode

Total cost: $5/month (just the Linode server)


Architecture Overview

Browser
   ↓
https://askloop-here.netlify.app  (React β€” Netlify)
   ↓ API calls (proxied through Netlify)
http://172.105.40.228/api/  (Django β€” Linode)
   ↓
Supabase PostgreSQL
Enter fullscreen mode Exit fullscreen mode

One thing I learned the hard way - Netlify is HTTPS but my Linode server is HTTP. Browsers block mixed content. The fix was proxying the API through Netlify using a _redirects file:

/api/* http://your-server-ip/api/:splat 200
/* /index.html 200
Enter fullscreen mode Exit fullscreen mode

Simple. Elegant. Took me 2 hours to figure out πŸ˜…


Django REST Framework - What I Learned

Custom User Model

Always use a custom user model from day one. Changing it later is painful.

class User(AbstractUser):
    bio = models.TextField(max_length=500, blank=True)
    reputation = models.IntegerField(default=0)
    role = models.CharField(max_length=20, choices=Role.choices)
    is_banned = models.BooleanField(default=False)

    # Use email as login field
    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = ["username"]
Enter fullscreen mode Exit fullscreen mode

JWT Authentication

I used dj-rest-auth with Simple-JWT. The setup is straightforward but the token refresh flow took some work to get right.

# settings.py
SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": timedelta(hours=1),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=7),
    "ROTATE_REFRESH_TOKENS": True,
}
Enter fullscreen mode Exit fullscreen mode

On the frontend, I added an Axios interceptor that automatically refreshes the token on 401 responses:

api.interceptors.response.use(
    (response) => response,
    async (error) => {
        if (error.response?.status == 401) {
            // Refresh token and retry
            const refresh = localStorage.getItem("refresh_token");
            const res = await axios.post("/api/auth/token/refresh/",
              { refresh }
            );
            localStorage.setItem("access_token", res.data.access);
            error.config.headers.Authorization = 
              `Bearer ${res.data.access}`;
            return api(error.config);
        }
        return Promise.reject(error);
    }
);
Enter fullscreen mode Exit fullscreen mode

Slug-Based URLs

Posts use slugs instead of IDs for better URLs.
Auto-generated from the title on save:

class Post(models.Model):
    title = models.CharField(max_length=300)
    slug = models.SlugField(unique=True, blank=True)

    def save(self, *args, **kwargs):
        if not self.slug:
            base = slugify(self.title)
            slug = base
            n = 1
            while Post.objects.filter(slug=slug).exists():
                slug = f"{base}-{n}"
                n += 1
            self.slug = slug
        super().save(*args, **kwargs)
Enter fullscreen mode Exit fullscreen mode

The Badge System

Badges auto-award when a user crosses reputation milestones. The key was overriding save() on the User model so it works regardless of how reputation is updated - even through Django admin:

def save(self, *args, **kwargs):
    if self.pk:
        try:
            old = User.objects.get(pk=pk)
            rep_changed = old.reputation != self.reputation
        except User.DoesNotExist:
            rep_changed = False
    else:
        rep_changed = False

    super().save(*args, **kwargs)

    if rep_changed:
        self._check_reputation_badges()

def _check_reputation_badge(self):
    milestones = {
        100: "rising_star",
        500: "contributor",
        1000: "expert",
        5000: "legend",
    }
    for threshold, slug in milestones.items():
        if self.reputation >= threshold:
            badge = Badge.objects.get(slug=slug)
            UserBadge.objects.get_or_create(
                user=self, badge=badge
            )
Enter fullscreen mode Exit fullscreen mode

React β€” Interesting Challenges

The Edited Badge Problem

Django sets created_at and updated_at in two separate operations β€”they're never exactly equal even on first save. Difference is ~0.5ms.

My first check updated_at !== created_at was always true, so everything showed "edited".

Fix: Use 1 second tolerance:

{comment.updated_at && comment.created_at &&
  (new Date(comment.updated_at).getTime() - 
   new Date(comment.created_at).getTime()) > 1000 && (
  <span className="text-xs italic text-muted">edited</span>
)}
Enter fullscreen mode Exit fullscreen mode

Load More Pagination

Django's paginated response returns next as an absolute URL like http://localhost:8000/api/posts/?page=2.

When I passed this to Axios, it double-prepended the base URL and got a 404.

Fix: Strip the origin before passing to Axios:

function relPath(url: string): string {
  try {
    const u = new URL(url);
    return u.pathname + u.search;
    // Returns: /api/posts/?page=2
  } catch {
    return url;
  }
}

// Usage
const res = await api.get(relPath(nextUrl));
Enter fullscreen mode Exit fullscreen mode

TipTap Image Alignment

TipTap's default Image extension doesn't support alignment. I built a custom FigureImage node that wraps <img> in <figure> with alignment controlled by CSS margins:

// Center: margin: 0 auto
// Left:   margin-right: auto  
// Right:  margin-left: auto
Enter fullscreen mode Exit fullscreen mode

Text-align on a block element doesn't work for images β€” margin-based alignment does.


Deployment β€” Things That Went Wrong

1. Gunicorn 203/EXEC Error

My first production deploy failed with status=203/EXEC. The unix socket path didn't exist. Fix: switched to TCP binding:

ExecStart=... gunicorn --bind 127.0.0.1:8000 ...
Enter fullscreen mode Exit fullscreen mode

2. InconsistentMigrationHistory

Adding django.contrib.sites to INSTALLED_APPS after socialaccount was already migrated caused this error. Fix:

python manage.py migrate sites --fake-initial
python manage.py migrate
Enter fullscreen mode Exit fullscreen mode

3. Mixed Content (HTTPS β†’ HTTP)

Netlify frontend (HTTPS) calling Linode API (HTTP) gets blocked by browsers. Already covered above β€” Netlify proxy via _redirects solves it.

4. Email Confirmation Template Error

allauth tried to render an email confirmation template that didn't exist. For now:

ACCOUNT_EMAIL_VERIFICATION = "none"
Enter fullscreen mode Exit fullscreen mode

Will add proper email confirmation later.


What I Would Do Differently

1. Plan the data models first

I added fields to models multiple times after the fact. Spending an extra hour designing models upfront saves days of migrations.

2. Use environment variables from day one

I hardcoded some values early and had to hunt them down later. Always use .env from the first commit.

3. Write API docs as you go

Documenting endpoints after the fact is painful. I should have maintained a simple list as I built.

4. Test on mobile early

The UI looked great on desktop but needed work on mobile. Test on real devices early and often.


The Numbers

Lines of code:  ~8,000
Time to build:  ~3 months (evenings + weekends)
Server cost:    $5/month (Linode)
Domain cost:    $0 (using free Netlify subdomain)
Total spent:    $15 so far
Enter fullscreen mode Exit fullscreen mode

What's Next

  • Password reset via email
  • HTTPS on the backend server
  • OAuth (Google + GitHub login)
  • Email notifications
  • Mobile app (maybe)

Source Code Available

If you want to build something similar or just learn from the code β€” I've made the complete source code available:

πŸ”— https://samwit.gumroad.com/l/askloop-app

Includes:

  • Complete Django backend
  • Complete React frontend
  • Production deployment guide
  • Full API documentation

Try It Live

https://askloop-here.netlify.app

Create an account, write a post, ask a question. The community is just getting started β€” you'd be one of the first members.


Final Thoughts

Building AskLoop taught me more than any tutorial ever could.

Every bug was a real problem to solve.
Every feature was a real decision to make.
Every deployment error was a real lesson learned.

If you're thinking about building something β€” stop thinking and start building.

The first version will be ugly. Ship it anyway.


Questions? Drop them in the comments below β€” I read and respond to every one.

Follow me for more build-in-public content.

Top comments (0)