<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Build In Public]]></title><description><![CDATA[Build In Public]]></description><link>https://build-in-public.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Build In Public</title><link>https://build-in-public.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 10 Sep 2026 18:15:33 GMT</lastBuildDate><atom:link href="https://build-in-public.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How I Built a Full Stack Community Platform from Scratch - And What I Learned]]></title><description><![CDATA[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 disap]]></description><link>https://build-in-public.hashnode.dev/how-i-built-a-full-stack-community-platform-from-scratch-and-what-i-learned</link><guid isPermaLink="true">https://build-in-public.hashnode.dev/how-i-built-a-full-stack-community-platform-from-scratch-and-what-i-learned</guid><category><![CDATA[Django]]></category><category><![CDATA[React]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Build In Public]]></category><category><![CDATA[Programming Blogs]]></category><dc:creator><![CDATA[Samwit Adhikary]]></dc:creator><pubDate>Thu, 09 Apr 2026 06:28:45 GMT</pubDate><content:encoded><![CDATA[<h2>The Problem I Kept Seeing</h2>
<p>I've spent a lot of time on developer platforms. And I kept noticing the same pattern everywhere.</p>
<p>You ask a question — it gets buried.
You share something valuable — it disappears in 48 hours.
A great discussion starts — nobody follows up.</p>
<p>There's always content. But rarely real depth.
There's always activity. But rarely meaningful interaction.</p>
<p>That bothered me enough to build something about it.</p>
<p>So I built <strong>AskLoop</strong> — a community platform that combines:</p>
<ul>
<li>✍️ Medium-style long-form articles</li>
<li>❓ Stack Overflow-style Q&amp;A with voting</li>
<li>💬 Dev.to-style forum discussions</li>
</ul>
<p>All in one place. No noise. No paywalls.</p>
<p><strong>Live demo: <a href="https://askloop-here.netlify.app">https://askloop-here.netlify.app</a></strong></p>
<hr />
<h2>What I Built</h2>
<p>Before diving into the technical stuff, here's what the platform does:</p>
<ul>
<li><strong>3 post types</strong> — Article, Q&amp;A, Forum Discussion</li>
<li><strong>Rich text editor</strong> with image upload (TipTap)</li>
<li><strong>Nested comments</strong> with threaded replies</li>
<li><strong>Q&amp;A voting</strong> — upvote/downvote + accept answer</li>
<li><strong>JWT authentication</strong> — register, login, refresh tokens</li>
<li><strong>User profiles</strong> — avatar, bio, social links, reputation</li>
<li><strong>Badge system</strong> — auto-awarded at reputation milestones</li>
<li><strong>Real-time notifications</strong> — likes, comments, follows</li>
<li><strong>Full-text search</strong> with filters</li>
<li><strong>Bookmarks, follows, reports</strong></li>
<li><strong>Account deletion</strong> with password confirmation</li>
</ul>
<hr />
<h2>The Tech Stack</h2>
<p>I wanted to use tools I could actually understand and explain — not just copy-paste a boilerplate.</p>
<h3>Backend</h3>
<pre><code>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
</code></pre>
<h3>Frontend</h3>
<pre><code>React 18 + TypeScript
Vite
TipTap (rich text editor)
shadcn/ui + Tailwind CSS
Axios + React Router v6
date-fns
</code></pre>
<h3>Infrastructure</h3>
<pre><code>Backend  → Linode (Ubuntu 22.04)
Frontend → Netlify
Database → Supabase PostgreSQL
</code></pre>
<p>Total cost: <strong>$5/month</strong> (just the Linode server)</p>
<hr />
<p>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 <code>_redirects</code> file:</p>
<pre><code>/api/*  http://your-server-ip/api/:splat  200
/*      /index.html                        200
</code></pre>
<p>Simple. Elegant. Took me 2 hours to figure out 😅</p>
<hr />
<h2>Django REST Framework — What I Learned</h2>
<h3>Custom User Model</h3>
<p>Always use a custom user model from day one. Changing it later is painful.</p>
<pre><code class="language-python">class User(AbstractUser):
    avatar     = models.ImageField(upload_to="avatars/", null=True, blank=True)
    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"]
</code></pre>
<h3>JWT Authentication</h3>
<p>I used <code>dj-rest-auth</code> with <code>Simple JWT</code>. The setup is straightforward but the token refresh flow took some work to get right.</p>
<pre><code class="language-python"># settings.py
SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME":  timedelta(hours=1),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=7),
    "ROTATE_REFRESH_TOKENS":  True,
}
</code></pre>
<p>On the frontend, I added an Axios interceptor that automatically refreshes the token on 401 responses:</p>
<pre><code class="language-typescript">api.interceptors.response.use(
  (response) =&gt; response,
  async (error) =&gt; {
    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);
  }
);
</code></pre>
<h3>Slug-Based URLs</h3>
<p>Posts use slugs instead of IDs for better URLs. Auto-generated from the title on save:</p>
<pre><code class="language-python">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)
</code></pre>
<h3>The Badge System</h3>
<p>Badges auto-award when a user crosses reputation milestones. The key was overriding <code>save()</code> on the User model so it works regardless of how reputation is updated — even through Django admin:</p>
<pre><code class="language-python">def save(self, *args, **kwargs):
    if self.pk:
        try:
            old = User.objects.get(pk=self.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_badges(self):
    milestones = {
        100:  "rising_star",
        500:  "contributor",
        1000: "expert",
        5000: "legend",
    }
    for threshold, slug in milestones.items():
        if self.reputation &gt;= threshold:
            badge = Badge.objects.get(slug=slug)
            UserBadge.objects.get_or_create(
                user=self, badge=badge
            )
</code></pre>
<hr />
<h2>React — Interesting Challenges</h2>
<h3>The Edited Badge Problem</h3>
<p>Django sets <code>created_at</code> and <code>updated_at</code> in two separate operations — they're never exactly equal even on first save. Difference is ~0.5ms.</p>
<p>My first check <code>updated_at !== created_at</code> was always true, so everything showed "edited".</p>
<p>Fix: Use 1 second tolerance:</p>
<pre><code class="language-typescript">{comment.updated_at &amp;&amp; comment.created_at &amp;&amp;
  (new Date(comment.updated_at).getTime() - 
   new Date(comment.created_at).getTime()) &gt; 1000 &amp;&amp; (
  &lt;span className="text-xs italic text-muted"&gt;edited&lt;/span&gt;
)}
</code></pre>
<h3>Load More Pagination</h3>
<p>Django's paginated response returns <code>next</code> as an <strong>absolute URL</strong> like <code>http://localhost:8000/api/posts/?page=2</code>.</p>
<p>When I passed this to Axios, it double-prepended the base URL and got a 404.</p>
<p>Fix: Strip the origin before passing to Axios:</p>
<pre><code class="language-typescript">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));
</code></pre>
<h3>TipTap Image Alignment</h3>
<p>TipTap's default Image extension doesn't support alignment. I built a custom <code>FigureImage</code> node that wraps <code>&lt;img&gt;</code> in <code>&lt;figure&gt;</code> with alignment controlled by CSS margins:</p>
<pre><code class="language-typescript">// Center: margin: 0 auto
// Left:   margin-right: auto  
// Right:  margin-left: auto
</code></pre>
<p>Text-align on a block element doesn't work for images — margin-based alignment does.</p>
<hr />
<h2>Deployment — Things That Went Wrong</h2>
<h3>1. Gunicorn 203/EXEC Error</h3>
<p>My first production deploy failed with <code>status=203/EXEC</code>. The unix socket path didn't exist. Fix: switched to TCP binding:</p>
<pre><code class="language-ini">ExecStart=... gunicorn --bind 127.0.0.1:8000 ...
</code></pre>
<h3>2. InconsistentMigrationHistory</h3>
<p>Adding <code>django.contrib.sites</code> to <code>INSTALLED_APPS</code> after <code>socialaccount</code> was already migrated caused this error. Fix:</p>
<pre><code class="language-bash">python manage.py migrate sites --fake-initial
python manage.py migrate
</code></pre>
<h3>3. Mixed Content (HTTPS → HTTP)</h3>
<p>Netlify frontend (HTTPS) calling Linode API (HTTP) gets blocked by browsers. Already covered above — Netlify proxy via <code>_redirects</code> solves it.</p>
<h3>4. Email Confirmation Template Error</h3>
<p><code>allauth</code> tried to render an email confirmation template that didn't exist. For now:</p>
<pre><code class="language-python">ACCOUNT_EMAIL_VERIFICATION = "none"
</code></pre>
<p>Will add proper email confirmation later.</p>
<hr />
<h2>What I Would Do Differently</h2>
<p><strong>1. Plan the data models first</strong></p>
<p>I added fields to models multiple times after the fact. Spending an extra hour designing models upfront saves days of migrations.</p>
<p><strong>2. Use environment variables from day one</strong></p>
<p>I hardcoded some values early and had to hunt them down later. Always use <code>.env</code> from the first commit.</p>
<p><strong>3. Write API docs as you go</strong></p>
<p>Documenting endpoints after the fact is painful. I should have maintained a simple list as I built.</p>
<p><strong>4. Test on mobile early</strong></p>
<p>The UI looked great on desktop but needed work on mobile. Test on real devices early and often.</p>
<hr />
<h2>The Numbers</h2>
<pre><code>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
</code></pre>
<hr />
<h2>What's Next</h2>
<ul>
<li>Password reset via email</li>
<li>HTTPS on the backend server</li>
<li>OAuth (Google + GitHub login)</li>
<li>Email notifications</li>
<li>Mobile app (maybe)</li>
</ul>
<hr />
<h2>Source Code Available</h2>
<p>If you want to build something similar or just learn from the code — I've made the complete source code available:</p>
<p><strong>🔗 <a href="https://samwit.gumroad.com/l/askloop-app">https://samwit.gumroad.com/l/askloop-app</a></strong></p>
<p>Includes:</p>
<ul>
<li>Complete Django backend</li>
<li>Complete React frontend</li>
<li>Production deployment guide</li>
<li>Full API documentation</li>
</ul>
<hr />
<h2>Try It Live</h2>
<p><strong><a href="https://askloop-here.netlify.app">https://askloop-here.netlify.app</a></strong></p>
<p>Create an account, write a post, ask a question. The community is just getting started — you'd be one of the first members.</p>
<hr />
<h2>Final Thoughts</h2>
<p>Building AskLoop taught me more than any tutorial ever could.</p>
<p>Every bug was a real problem to solve.
Every feature was a real decision to make.
Every deployment error was a real lesson learned.</p>
<p>If you're thinking about building something — stop thinking and start building.</p>
<p>The first version will be ugly. Ship it anyway.</p>
<hr />
<p><em>Questions? Drop them in the comments below — I read and respond to every one.</em></p>
<p><em>Follow me for more build-in-public content.</em></p>
]]></content:encoded></item></channel></rss>