<?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: SweepBase</title>
    <description>The latest articles on DEV Community by SweepBase (@sweepbase).</description>
    <link>https://dev.to/sweepbase</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3886467%2F93a893fd-d4b9-474b-a682-cf8587a48db0.png</url>
      <title>DEV Community: SweepBase</title>
      <link>https://dev.to/sweepbase</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sweepbase"/>
    <language>en</language>
    <item>
      <title>What I learned shipping a Next.js 15 + CSV side project</title>
      <dc:creator>SweepBase</dc:creator>
      <pubDate>Thu, 30 Apr 2026 10:48:50 +0000</pubDate>
      <link>https://dev.to/sweepbase/what-i-learned-shipping-a-nextjs-15-csv-side-project-37po</link>
      <guid>https://dev.to/sweepbase/what-i-learned-shipping-a-nextjs-15-csv-side-project-37po</guid>
      <description>&lt;p&gt;I shipped a small side project this year: &lt;a href="https://sweepbase.net" rel="noopener noreferrer"&gt;sweepbase.net&lt;/a&gt;, a comparison site for crypto debit and credit cards. 139 cards, no DB, the whole dataset is one CSV file in the repo.&lt;/p&gt;

&lt;p&gt;Here are the things I'd actually tell another dev about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  CSV beats a DB more often than people admit
&lt;/h2&gt;

&lt;p&gt;The whole catalog is &lt;code&gt;data.csv&lt;/code&gt;, parsed at boot, validated with Zod. Reads outnumber writes by something like 10,000 to 1, and most "writes" are me fixing a number once a month.&lt;/p&gt;

&lt;p&gt;For that load profile, a database is theatre. CSV in a public repo gives me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One source of truth, version controlled&lt;/li&gt;
&lt;li&gt;Diff-able commits when I change a number&lt;/li&gt;
&lt;li&gt;No admin UI to build&lt;/li&gt;
&lt;li&gt;An auditable timeline anybody can inspect&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When somebody asks "why did you change Crypto.com APY", I link the commit. That answer is more reassuring than any dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zod earns its rent
&lt;/h2&gt;

&lt;p&gt;Zod's schema does double duty: it validates at boot, and it generates the TypeScript type via &lt;code&gt;z.infer&lt;/code&gt;. One source for shape, no drift between runtime and compile time.&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;CardSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;fxMargin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;atmFee&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Card&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infer&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;CardSchema&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a row in the CSV is malformed, the build fails. I never ship broken data without knowing.&lt;/p&gt;

&lt;h2&gt;
  
  
  ISR is the right default for content sites
&lt;/h2&gt;

&lt;p&gt;Next.js 15.1 App Router with &lt;code&gt;revalidate: 3600&lt;/code&gt; on every page. The data changes a few times a week. There is no reason to re-render on every request. Lighthouse stays at 100 across the catalog because the rendered HTML is essentially static, and the framework refreshes it every hour.&lt;/p&gt;

&lt;p&gt;I had to fight the urge to reach for SSR or client-side fetching. Neither belongs here.&lt;/p&gt;

&lt;h2&gt;
  
  
  React.cache() is underrated
&lt;/h2&gt;

&lt;p&gt;Multiple components in a single page render call the same &lt;code&gt;getCards()&lt;/code&gt; function. Without &lt;code&gt;React.cache()&lt;/code&gt;, the CSV gets parsed once per call site. Wrapped in &lt;code&gt;React.cache()&lt;/code&gt;, it parses once per request. Easy 10x latency win that I almost missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Filters as predicates beats SQL for small data
&lt;/h2&gt;

&lt;p&gt;37 category pages (USA, no-KYC, self-custody, travel, and so on), all rendered from the same Server Component. The category-specific logic lives in &lt;code&gt;lib/filters.ts&lt;/code&gt;:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isSelfCustody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Card&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;custody&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;self&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isUSACompatible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Card&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;regions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;USA&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;Adding a new category page is a 6-line PR: filter, slug, name. No migration, no index to remember.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Started the public CSV from day one. I used Notion for the first month, lost a week porting it.&lt;/li&gt;
&lt;li&gt;Set up Sentry before shipping, not after the first ghost bug report.&lt;/li&gt;
&lt;li&gt;Wrote the report-error button in week 1. Real user reports caught more bad data than my own auditing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where to look
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Live: &lt;a href="https://sweepbase.net" rel="noopener noreferrer"&gt;sweepbase.net&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Dataset: &lt;a href="https://sweepbase.net/datasets/data.csv" rel="noopener noreferrer"&gt;/datasets/data.csv&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Calculator: &lt;a href="https://sweepbase.net/calculator" rel="noopener noreferrer"&gt;/calculator&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see the schema or argue with one of my ratings, both are public. The CSV is the source of truth.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>sideprojects</category>
    </item>
  </channel>
</rss>
