<?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: Dreiev</title>
    <description>The latest articles on DEV Community by Dreiev (@dreiev).</description>
    <link>https://dev.to/dreiev</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%2F4111787%2Fad790007-efd6-494e-a223-117a2f8fd674.png</url>
      <title>DEV Community: Dreiev</title>
      <link>https://dev.to/dreiev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dreiev"/>
    <language>en</language>
    <item>
      <title>Building a game server tracker: what I learned exposing a public JSON API</title>
      <dc:creator>Dreiev</dc:creator>
      <pubDate>Sun, 06 Sep 2026 02:53:04 +0000</pubDate>
      <link>https://dev.to/dreiev/building-a-game-server-tracker-what-i-learned-exposing-a-public-json-api-o1p</link>
      <guid>https://dev.to/dreiev/building-a-game-server-tracker-what-i-learned-exposing-a-public-json-api-o1p</guid>
      <description>&lt;p&gt;A few months ago I shipped &lt;a href="https://l2calendar.com" rel="noopener noreferrer"&gt;L2 Calendar&lt;/a&gt;, a tracker for Lineage 2 private server openings. Players kept asking the same thing: "when's the next Interlude server?" and server owners had no good place to announce openings. The site solves that. Last week I opened up the data as a public JSON API, and I want to walk through the decisions that mattered — because two of them bit me harder than I expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data model is deceptively simple
&lt;/h2&gt;

&lt;p&gt;A server opening looks like this: name, website, chronicle (Interlude, High Five, Classic...), rate (x5, x1200, whatever the owner decided), opening date, and labels like "PvP" or "RP". That's it. Four tables in MySQL: &lt;code&gt;servers&lt;/code&gt;, &lt;code&gt;chronicles&lt;/code&gt;, &lt;code&gt;labels&lt;/code&gt;, and a many-to-many &lt;code&gt;server_labels_map&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The trap wasn't the schema. It was the dates.&lt;/p&gt;

&lt;h2&gt;
  
  
  mysql2's dateStrings setting, and why DATETIME still bites you
&lt;/h2&gt;

&lt;p&gt;I had this in my connection config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;dateStrings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DATE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sounds like "give me all dates as strings, please." It doesn't. It means: convert &lt;strong&gt;columns typed as DATE&lt;/strong&gt; to strings. DATETIME columns still come back as JavaScript &lt;code&gt;Date&lt;/code&gt; objects.&lt;/p&gt;

&lt;p&gt;Why does that matter? My display code treated every date as a string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isUtc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Z&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One day a &lt;code&gt;TypeError: value.endsWith is not a function&lt;/code&gt; started showing up in production for rows where &lt;code&gt;opening_datetime_utc&lt;/code&gt; was populated. A DATETIME column came back as a &lt;code&gt;Date&lt;/code&gt; object, &lt;code&gt;.endsWith&lt;/code&gt; doesn't exist on it, crash.&lt;/p&gt;

&lt;p&gt;The fix was boring but worth writing down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawUtc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;opening_datetime_utc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;utc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rawUtc&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;rawUtc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rawUtc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;mysql2's type coercion is per-column-type, not per-config-intent. If you have mixed DATE and DATETIME columns feeding the same code path, normalize at the boundary or you'll debug this at 2am eventually.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API itself is one route handler
&lt;/h2&gt;

&lt;p&gt;I didn't build a separate service. The API is a route handler in the same Next.js app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chronicle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chronicle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// one query with a LEFT JOIN to chronicles, one for labels&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;servers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;corsHeaders&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filter by chronicle slug, join labels in a second query, ship the JSON. There's a second &lt;code&gt;OPTIONS&lt;/code&gt; handler for CORS preflight with a 24h &lt;code&gt;Access-Control-Max-Age&lt;/code&gt; so browsers don't re-preflight on every request.&lt;/p&gt;

&lt;p&gt;464 servers tracked right now, response is a few KB, no auth. If the site dies, the API dies — and I'm fine with that trade until there's a reason to split it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Opening CORS on purpose
&lt;/h2&gt;

&lt;p&gt;Default Next.js APIs are same-origin. For a public API that people should use from browsers and Discord bots, that's useless. Two lines on the GET handler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;corsHeaders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Access-Control-Allow-Origin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Access-Control-Allow-Methods&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET, OPTIONS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything else in the app — forms, admin routes, auth — keeps the default policy. Opening CORS on one read-only route costs nothing security-wise because the data is already public on the homepage; it just removes the friction for anyone building a dashboard or a Discord bot on top of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell anyone doing this with game data
&lt;/h2&gt;

&lt;p&gt;Three things, in order of how much they hurt when ignored:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Normalize dates at the data boundary.&lt;/strong&gt; Game servers live and die by opening dates and timezones. Owners submit "opening at midnight my time" and your users are in twelve timezones. Store UTC, convert at display, and treat every date coming out of mysql2 as suspicious until you've checked its type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache the expensive query.&lt;/strong&gt; The homepage hammers the same data the API serves. One in-memory cache with a TTL both can share — invalidate on admin edits — beats two separate query paths that drift.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give the data away.&lt;/strong&gt; The tracker gets more useful when other people build on it. The Discord bot someone else writes drives traffic back here. Hoarding the JSON would've saved me nothing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you play Lineage 2 — or you just want to poke at the API — the endpoint is &lt;code&gt;https://l2calendar.com/api/servers?lang=en&lt;/code&gt;, no key, CORS open. And if you run an L2 server, &lt;a href="https://l2calendar.com/addnews" rel="noopener noreferrer"&gt;listings are free&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Questions about the mysql2 date handling or the CORS setup are welcome in the comments.&lt;/p&gt;

</description>
      <category>api</category>
      <category>mysql</category>
      <category>node</category>
      <category>gaming</category>
    </item>
  </channel>
</rss>
