<?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: Dragutin Spasenović</title>
    <description>The latest articles on DEV Community by Dragutin Spasenović (@dragspas).</description>
    <link>https://dev.to/dragspas</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%2F3905697%2F25c88298-566d-478a-8213-2d308185055a.JPG</url>
      <title>DEV Community: Dragutin Spasenović</title>
      <link>https://dev.to/dragspas</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dragspas"/>
    <language>en</language>
    <item>
      <title>I Built a Invoice Generator That Collects Zero Data — Here's the Tech Stack</title>
      <dc:creator>Dragutin Spasenović</dc:creator>
      <pubDate>Thu, 30 Apr 2026 08:40:46 +0000</pubDate>
      <link>https://dev.to/dragspas/i-built-a-invoice-generator-that-collects-zero-data-heres-the-tech-stack-4ihh</link>
      <guid>https://dev.to/dragspas/i-built-a-invoice-generator-that-collects-zero-data-heres-the-tech-stack-4ihh</guid>
      <description>&lt;p&gt;&lt;em&gt;No accounts. No tracking. No backend. Just PDFs.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;After spending way too long watching freelancer friends wrestle with bloated invoicing SaaS tools that require sign-ups, lock features behind paywalls, and quietly harvest business data, I decided to build something better.&lt;/p&gt;

&lt;p&gt;Meet &lt;strong&gt;&lt;a href="https://invoinova.com" rel="noopener noreferrer"&gt;InvoiNova&lt;/a&gt;&lt;/strong&gt; — a free, browser-based invoice generator that generates professional PDFs instantly, without ever touching a server.&lt;/p&gt;

&lt;p&gt;We just launched on &lt;strong&gt;&lt;a href="https://www.producthunt.com/products/invoinova-free-invoice-generator" rel="noopener noreferrer"&gt;Product Hunt&lt;/a&gt;&lt;/strong&gt; — if you find this useful, an upvote goes a long way for an indie project 🙏&lt;/p&gt;




&lt;h2&gt;
  
  
  The Core Constraint: No Backend
&lt;/h2&gt;

&lt;p&gt;The most interesting engineering challenge wasn't building features — it was building everything &lt;em&gt;without&lt;/em&gt; a backend. This single constraint shaped every technical decision.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why no backend?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most "free" invoice tools are freemium traps. They need your email to start the funnel. They store your client names and financial data to create lock-in. They sell that data or use it for targeting.&lt;/p&gt;

&lt;p&gt;I wanted to make that architecturally impossible. If there's no server receiving data, there's nothing to leak, sell, or breach.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;React 19 + Vite 7
Tailwind CSS 4
jsPDF
i18next (7 languages)
Cloudflare Pages (hosting)
PostHog Analytics (privacy-preserving)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why jsPDF over react-pdf?
&lt;/h3&gt;

&lt;p&gt;This was a deliberate trade-off. &lt;code&gt;react-pdf&lt;/code&gt; is more declarative and React-idiomatic, but it ships a significantly heavier bundle and the rendering pipeline involves more async complexity.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;jsPDF&lt;/code&gt; generates PDFs synchronously in-browser, is battle-tested, and the bundle impact is manageable — especially with lazy loading:&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="c1"&gt;// PDF generation is lazy-loaded on first use&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;generatePDF&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;jsPDF&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jspdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// ... generation logic&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This keeps the initial bundle lean and only loads the library when the user actually wants a PDF.&lt;/p&gt;

&lt;h3&gt;
  
  
  Shared Design Tokens for PDF/Preview Consistency
&lt;/h3&gt;

&lt;p&gt;One problem with client-side PDF generation: your PDF looks different from your on-screen preview. I solved this with a shared styles utility:&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="c1"&gt;// src/utils/pdf/styles.js&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;PDF_STYLES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#1a1a2e&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;accent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#6c63ff&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;textSecondary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#4b5563&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#e5e7eb&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;span class="na"&gt;fonts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;helvetica&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// jsPDF built-in closest to Plus Jakarta Sans&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;helvetica&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;span class="na"&gt;spacing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;lineHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&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;Both the live preview component and the PDF generator import from this file. When you tweak a color, it updates in both places.&lt;/p&gt;




&lt;h2&gt;
  
  
  LocalStorage as the "Database"
&lt;/h2&gt;

&lt;p&gt;Everything that needs persistence lives in &lt;code&gt;localStorage&lt;/code&gt;. Invoice history, templates (coming in the next phase), clients, language preferences — all of it.&lt;/p&gt;

&lt;p&gt;The schema is simple:&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="c1"&gt;// Invoice history index&lt;/span&gt;
&lt;span class="c1"&gt;// key: inv_history_index&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clientName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;invoiceNo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;// Individual invoice&lt;/span&gt;
&lt;span class="c1"&gt;// key: inv_{timestamp}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{...},&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{...},&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&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;A few things I learned the hard way:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FIFO matters.&lt;/strong&gt; I cap history at 20 invoices. When the limit is hit, the oldest entry is evicted. Without this, power users would eventually hit the 5MB localStorage quota.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Index separately from data.&lt;/strong&gt; Storing a lightweight metadata index separately from the full invoice JSON means rendering the history drawer is fast — you only load full invoice data when the user actually clicks "Load."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't store derived data.&lt;/strong&gt; Totals, subtotals, tax amounts — all recalculated on load. Only store the source of truth (items with qty/price/taxRate), never the computed outputs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Internationalisation Without a Backend
&lt;/h2&gt;

&lt;p&gt;Supporting 7 languages (EN, DE, ES, PT, SR, BS, HR) with &lt;code&gt;i18next&lt;/code&gt; on a fully static site required some thought.&lt;/p&gt;

&lt;p&gt;All locale files are bundled at build time. Language detection uses the &lt;code&gt;i18next-browser-languagedetector&lt;/code&gt; plugin, which checks &lt;code&gt;localStorage&lt;/code&gt; → &lt;code&gt;navigator.language&lt;/code&gt; → fallback to &lt;code&gt;en&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The interesting part is the niche landing pages. Each regional page (e.g. &lt;code&gt;/germany&lt;/code&gt;, &lt;code&gt;/spain&lt;/code&gt;, &lt;code&gt;/brazil&lt;/code&gt;) auto-switches the UI language on mount:&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="c1"&gt;// NichePage.jsx&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;preset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;i18n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;language&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;preset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;i18n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;changeLanguage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;preset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;preset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Combined with &lt;code&gt;hreflang&lt;/code&gt; tags, this means &lt;code&gt;/germany&lt;/code&gt; serves German UI and signals to Google that it's the canonical German-language version of the tool.&lt;/p&gt;




&lt;h2&gt;
  
  
  Currency Handling: The Ambiguous $ Problem
&lt;/h2&gt;

&lt;p&gt;We support 33 currencies. Several Latin American currencies (ARS, COP, CLP, UYU) share the &lt;code&gt;$&lt;/code&gt; symbol with USD.&lt;/p&gt;

&lt;p&gt;A naive implementation would display &lt;code&gt;$ 5,000&lt;/code&gt; on a Colombian Peso invoice, which looks identical to USD. A client receiving that invoice might genuinely not notice the currency discrepancy.&lt;/p&gt;

&lt;p&gt;The fix:&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;AMBIGUOUS_SYMBOLS&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;ARS&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;COP&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;CLP&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;UYU&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;MXN&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;function&lt;/span&gt; &lt;span class="nf"&gt;getCurrencyDisplay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;amount&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;symbol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;AMBIGUOUS_SYMBOLS&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="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; $`&lt;/span&gt;  &lt;span class="c1"&gt;// e.g. "ARS $"&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getCurrencySymbol&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nf"&gt;formatAmount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This renders "ARS $ 5,000" instead of "$ 5,000" — unambiguous for international clients.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Validation Philosophy
&lt;/h2&gt;

&lt;p&gt;Form validation blocks PDF download but never blocks preview. The reasoning: you might want to preview a half-finished invoice to check the layout. You should never be able to download and accidentally send an invoice missing the client name or invoice number.&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="c1"&gt;// Preview button: always works&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="nx"&gt;onClick&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;openPreview&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;Preview&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/button&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;
&lt;span class="c1"&gt;// Download button: validates first&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="nx"&gt;onClick&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleDownload&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;Download&lt;/span&gt; &lt;span class="nx"&gt;PDF&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/button&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&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;handleDownload&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;errors&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validateInvoiceForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setValidationErrors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;scrollToFirstError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateAndDownloadPDF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formData&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;One edge case worth calling out: &lt;strong&gt;partially filled line items vs empty line items.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An empty row (all fields zero/empty) is silently ignored — no validation error. A partially filled row (has a description but no price) &lt;em&gt;is&lt;/em&gt; an error and blocks download. This distinction matters because the form always renders with some empty rows as placeholders.&lt;/p&gt;




&lt;h2&gt;
  
  
  Deployment: Cloudflare Pages
&lt;/h2&gt;

&lt;p&gt;Hosting a React SPA on Cloudflare Pages has one non-obvious requirement: client-side routing.&lt;/p&gt;

&lt;p&gt;React Router handles navigation in-browser, but if someone navigates directly to &lt;code&gt;invoinova.com/germany&lt;/code&gt;, Cloudflare tries to find a &lt;code&gt;germany.html&lt;/code&gt; file, fails, and returns a 404.&lt;/p&gt;

&lt;p&gt;The fix is a single file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;// &lt;span class="n"&gt;public&lt;/span&gt;/&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="n"&gt;redirects&lt;/span&gt;
/* /&lt;span class="n"&gt;index&lt;/span&gt;.&lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells Cloudflare Pages to serve &lt;code&gt;index.html&lt;/code&gt; for any path, letting React Router take over. Simple, but easy to forget when you're focused on code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://invoinova.com" rel="noopener noreferrer"&gt;invoinova.com&lt;/a&gt;&lt;/strong&gt; — no sign-up, works immediately.&lt;/p&gt;

&lt;p&gt;If you're launching on Product Hunt or want to give feedback, you can find the launch here: &lt;strong&gt;&lt;a href="https://www.producthunt.com/products/invoinova-free-invoice-generator" rel="noopener noreferrer"&gt;Product Hunt — InvoiNova&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Questions about the tech decisions, the jsPDF implementation, or the localStorage architecture? Happy to dig into any of it in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built solo. Stack: React + jsPDF + Cloudflare Pages. Honest about what it is.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>frontend</category>
      <category>softwaredevelopment</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
