<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Tommy</title>
    <description>The latest articles on DEV Community by Tommy (@banh).</description>
    <link>https://dev.to/banh</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4006888%2F21568185-e0ec-46eb-b848-6b71b7411110.png</url>
      <title>DEV Community: Tommy</title>
      <link>https://dev.to/banh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/banh"/>
    <language>en</language>
    <item>
      <title>Twitter Bookmark Organizer</title>
      <dc:creator>Tommy</dc:creator>
      <pubDate>Sun, 28 Jun 2026 19:01:14 +0000</pubDate>
      <link>https://dev.to/banh/how-i-built-a-secure-rest-api-to-organize-my-twitter-bookmarks-j96</link>
      <guid>https://dev.to/banh/how-i-built-a-secure-rest-api-to-organize-my-twitter-bookmarks-j96</guid>
      <description>&lt;h1&gt;
  
  
  How I Built a Secure REST API to Organize My Twitter Bookmarks
&lt;/h1&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;My Twitter bookmark list was a graveyard. Hundreds of saved tweets — digital art, career advice, ai news — with no way to find anything. Twitter's native bookmarks have zero organization. I needed a way to save a URL, tag it, and pull it back up later. Currently, Twitter does have a folder system, but it is locked behind a pay wall.&lt;/p&gt;

&lt;p&gt;So I built one. A backend REST API with exactly three features: store a bookmark, tag it, and filter by tag. No Chrome extension, no AI auto-tagging, no slick UI. Just a backend I could actually ship.&lt;/p&gt;




&lt;h2&gt;
  
  
  Schema Design Decisions
&lt;/h2&gt;

&lt;p&gt;The database has two tables: &lt;code&gt;users&lt;/code&gt; and &lt;code&gt;bookmarks&lt;/code&gt;. The decisions behind them were deliberate.&lt;/p&gt;

&lt;p&gt;On the &lt;code&gt;users&lt;/code&gt; table, &lt;code&gt;username&lt;/code&gt; is &lt;code&gt;UNIQUE NOT NULL&lt;/code&gt; enforced at the database level — not just in the app. App-level checks can have race conditions (two requests land at the same millisecond; both pass the check; both try to insert). A &lt;code&gt;UNIQUE&lt;/code&gt; constraint at the DB level makes that a hard stop, not a maybe.&lt;/p&gt;

&lt;p&gt;On the &lt;code&gt;bookmarks&lt;/code&gt; table, &lt;code&gt;user_id&lt;/code&gt; is a foreign key with &lt;code&gt;ON DELETE CASCADE&lt;/code&gt;. Every bookmark is owned by exactly one user. If that user is deleted, their bookmarks go with them — automatically, without needing a separate cleanup query. No orphaned rows left rotting in the database.&lt;/p&gt;

&lt;p&gt;I also separated my two database tools intentionally. Knex handles migrations — versioned, rollbackable schema changes. &lt;code&gt;pg.Pool&lt;/code&gt; handles all runtime queries. They look interchangeable at first glance, but they're not. Knex is for evolving the schema; the pool is for talking to it day-to-day. Mixing them causes subtle bugs that are hard to trace.&lt;/p&gt;




&lt;h2&gt;
  
  
  API Architecture
&lt;/h2&gt;

&lt;p&gt;Five routes: &lt;code&gt;POST /register&lt;/code&gt;, &lt;code&gt;POST /login&lt;/code&gt;, &lt;code&gt;POST /storeBookmark&lt;/code&gt;, &lt;code&gt;GET /filterBookmarks&lt;/code&gt;, and &lt;code&gt;GET /bookmarks&lt;/code&gt;. The auth and bookmark endpoints are kept separate on purpose — different middleware applies to each.&lt;/p&gt;

&lt;p&gt;Protected routes follow a consistent middleware chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rateLimiter → authenticateUser → validateBookmark → handler
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order is intentional. Rate limiting runs first because it's the cheapest thing to reject — no DB call, no token verification, just check the IP and bail. Auth runs next. Validation runs last, right before the handler that does real work.&lt;/p&gt;

&lt;p&gt;Every catch block in every route calls &lt;code&gt;next(error)&lt;/code&gt; — never &lt;code&gt;res.json()&lt;/code&gt;. This ensures all errors, no matter where they originate, flow through a single centralized error handler. That handler uses a custom &lt;code&gt;AppError&lt;/code&gt; class hierarchy (&lt;code&gt;AuthError&lt;/code&gt;, &lt;code&gt;ValidationError&lt;/code&gt;, &lt;code&gt;ConflictError&lt;/code&gt;) to map errors to the right HTTP status. And critically: any unrecognized 500-level error gets its internal message stripped. Clients only ever see &lt;code&gt;"Internal Server Error"&lt;/code&gt; — never a stack trace, never a SQL query, never a file path.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Debugging Rabbit Hole
&lt;/h2&gt;

&lt;p&gt;I spent hours staring at &lt;code&gt;ECONNREFUSED&lt;/code&gt; errors on my database connection. I checked my Supabase credentials. I regenerated the connection string. I restarted the server repeatedly. The credentials looked right in my &lt;code&gt;.env&lt;/code&gt; file. I would go back and forth from one project to the current one just see if I typed something wrong. Nothing worked.&lt;/p&gt;

&lt;p&gt;The fix was one line I had forgotten: &lt;code&gt;dotenv.config()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dotenv&lt;/code&gt; reads your &lt;code&gt;.env&lt;/code&gt; file and loads the values into &lt;code&gt;process.env&lt;/code&gt;. But if you never call it, &lt;code&gt;process.env.PG_CONNECTION_STRING&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt; when the app starts — and PostgreSQL tries to connect to nothing. The error looks exactly like a database problem. It isn't. It's an environment problem masquerading as one.&lt;/p&gt;

&lt;p&gt;The lesson I took: when you see &lt;code&gt;ECONNREFUSED&lt;/code&gt;, log &lt;code&gt;process.env.PG_CONNECTION_STRING&lt;/code&gt; before you touch anything else. If it's &lt;code&gt;undefined&lt;/code&gt;, stop — the bug isn't in your database config, it's upstream. Also, &lt;code&gt;dotenv.config()&lt;/code&gt; must be the very first call in your entry file. Any module loaded before it that reads &lt;code&gt;process.env&lt;/code&gt; will get &lt;code&gt;undefined&lt;/code&gt;, even if you add the call later.&lt;/p&gt;




&lt;h2&gt;
  
  
  Outcome
&lt;/h2&gt;

&lt;p&gt;The app is a working REST API where authenticated users can store, tag, and retrieve bookmarks. Under the hood it ships six independent security layers: parameterized SQL queries (injection prevention), async bcrypt hashing (password security), stateless JWT auth with 15-minute expiry, two-tier rate limiting (stricter on auth routes to blunt brute-force attempts), input validation with &lt;code&gt;express-validator&lt;/code&gt;, and BOLA prevention — every bookmark query is scoped to &lt;code&gt;req.user.userId&lt;/code&gt; so no user can read another's data.&lt;/p&gt;

&lt;p&gt;It also has a test suite: integration tests with Jest and Supertest against a real database, plus a mock-based suite that simulates database crashes to verify the error handler works without needing a real failure.&lt;/p&gt;

&lt;p&gt;This project covered Weeks 9–12 of my SWE roadmap: building servers, designing database schemas, layering in security, and proving correctness with tests. More importantly, it taught me that the bugs that cost the most time are rarely in the code you're focused on — they're in the invisible infrastructure surrounding it.&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>showdev</category>
      <category>sideprojects</category>
    </item>
  </channel>
</rss>
