Engineering

The Zero-DB Architecture: Why Your URL is the Best Backend

The Zero-DB Architecture: Why Your URL is the Best Backend

Introduction

I have a confession to make. Early in my career, I built a simple invoicing generator for freelancers. It did one thing: took a few inputs, such as hours worked, rate, and tax, and spat out a PDF. Simple, right? But being the "architect" I thought I was, I spun up a PostgreSQL instance, a Node API layer, an authentication service, and a Redis cache for session management.

I spent three weeks building the infrastructure and three hours building the actual invoice generator. The result? I paid $40 a month to host a database that held exactly 14 rows of data for the three friends who actually used the tool.

We have a tendency to over-engineer persistence. We treat every piece of state as if it needs to be etched into stone tablets (or AWS RDS) for eternity. But sometimes, data is ephemeral. Sometimes, the most resilient database you have is the address bar in the user's browser.

Recently, I found myself needing a "Payday Ticker", a visual dashboard to remind me (and my anxiety) exactly how much revenue is generating in real-time, or how long until the next payout. Instead of spinning up a backend, I decided to architect this entirely around the URL.

Today, we are going to build a high-performance, stateless financial visualizer using TypeScript. We will tackle state serialization and compression alongside the mathematics of time-based currency rendering without spending a dime on hosting.

The Problem: Friction in Sharing Context

When you build a tool like a salary calculator or a countdown timer, the value lies in the configuration.

If I configure my dashboard to track a specific project's run rate, I want to send that exact view to my co-founder. If I built this the "traditional" way:

  1. I create an account.
  2. I save my config.
  3. I invite my co-founder.
  4. They create an account.
  5. They view the dashboard.

This is death by friction.

The "System Thinking" approach here is to invert the dependency. Instead of the server holding the truth, the client holds the truth, and the URL is the transport layer. The link is the record.

The Solution: State-as-URL

To make this work, we need a robust pipeline. We can't just dump JSON into a query parameter because it hits length limits and looks unprofessional. We need a mechanism to:

  1. Define a Schema: Strictly type our application state.
  2. Compress: Shrink that state to a minimal footprint.
  3. Encode: Make it URL-safe.
  4. Hydrate: Restore the application instantly on load.

We will build a "Payday Ticker" that tracks earnings to the millisecond. It supports comparative modes (e.g., "Me vs. Elon Musk") and handles holiday logic, all stored in a string of characters.

Implementation

Let's get our hands dirty. We'll use React and TypeScript, but the patterns apply anywhere.

1. The Strict Schema

First, we define what our "database" looks like. I use Zod for this because it allows us to validate the URL state at runtime. If a user messes with the URL parameters, we want to fallback gracefully, not crash.

import { z } from 'zod';
 
// The shape of our application state
export const AppConfigSchema = z.object({
  salary: z.number().min(0),
  currency: z.enum(['USD', 'KRW', 'EUR', 'JPY']),
  payDay: z.number().min(1).max(31),
  cycle: z.enum(['monthly', 'weekly', 'hourly']),
  // The "Elon Mode" - comparing your income to a billionaire's
  compareTarget: z.enum(['none', 'musk', 'bezos']).default('none'),
  includeHolidays: z.boolean().default(true),
});
 
export type AppConfig = z.infer<typeof AppConfigSchema>;
 
const DEFAULT_CONFIG: AppConfig = {
  salary: 60000,
  currency: 'USD',
  payDay: 25,
  cycle: 'monthly',
  compareTarget: 'none',
  includeHolidays: true,
};

2. The Compressor (The "Database" Engine)

Storing raw JSON in a URL is inefficient. A configuration object might be 200 characters. We can shrink that. I recommend lz-string for compression. It is battle-tested and efficient for text.

Here is a utility class to handle the serialization pipeline:

import LZString from 'lz-string';
 
export const UrlState = {
  /**
   * Serializes the config object into a URL-safe string
   */
  encode: (config: AppConfig): string => {
    try {
      const json = JSON.stringify(config);
      // Compress to Base64 to save space and ensure URL safety
      return LZString.compressToEncodedURIComponent(json);
    } catch (e) {
      console.error("Failed to encode state", e);
      return "";
    }
  },
 
  /**
   * Hydrates the config from the URL, with schema validation
   */
  decode: (hash: string): AppConfig => {
    try {
      if (!hash) return DEFAULT_CONFIG;
      
      const json = LZString.decompressFromEncodedURIComponent(hash);
      if (!json) return DEFAULT_CONFIG;
 
      const parsed = JSON.parse(json);
      
      // Validate against schema to ensure data integrity
      const result = AppConfigSchema.safeParse(parsed);
      
      if (!result.success) {
        console.warn("URL state corrupted, reverting to default");
        return DEFAULT_CONFIG;
      }
      
      return result.data;
    } catch (e) {
      return DEFAULT_CONFIG;
    }
  }
};

3. Synchronization Hook

Now we need a React hook that keeps our local state in sync with the browser URL. This ensures that if the user copies the link at any moment, they capture the exact configuration they are looking at.

import { useState, useEffect, useCallback } from 'react';
 
export function useUrlSyncedState() {
  // Initialize state from the current URL hash on mount
  const [config, setConfig] = useState<AppConfig>(() => {
    const hash = window.location.hash.slice(1); // Remove '#'
    return UrlState.decode(hash);
  });
 
  // Update URL whenever config changes
  useEffect(() => {
    const hash = UrlState.encode(config);
    // use replaceState so we don't pollute the browser history stack
    // with every minor keystroke change
    window.history.replaceState(null, '', `#${hash}`);
  }, [config]);
 
  return [config, setConfig] as const;
}

4. The Logic: Visualizing the Flow of Money

Now for the fun part. The application logic. We want to see money ticking up in real-time. This requires calculating the "per millisecond" earning rate.

Using setInterval is okay, but for a smooth, dashboard-quality animation, we should use requestAnimationFrame. This aligns updates with the screen's refresh rate.

import { useEffect, useState, useRef } from 'react';
 
// Hardcoded specific salaries for comparison (approximate annual)
const COMPARISONS = {
  musk: 24000000000, // It varies, but let's say it's a lot
  bezos: 2000000000,
  none: 0
};
 
export function useMoneyTicker(config: AppConfig) {
  const [currentEarnings, setCurrentEarnings] = useState(0);
  const [comparisonEarnings, setComparisonEarnings] = useState(0);
  
  // Ref to store the start of the pay period
  const startTimeRef = useRef<number>(getCycleStartTime(config));
 
  useEffect(() => {
    // Recalculate start time if config changes
    startTimeRef.current = getCycleStartTime(config);
    
    let animationFrameId: number;
 
    const tick = () => {
      const now = Date.now();
      const elapsedMs = now - startTimeRef.current;
      
      // Calculate standard hourly rate -> ms rate
      // Assuming 2080 work hours a year for simplicity in this snippet
      const annualToMs = (salary: number) => salary / (52 * 40 * 60 * 60 * 1000);
      
      // Your earnings
      const myRate = annualToMs(config.salary);
      setCurrentEarnings(elapsedMs * myRate);
 
      // Comparison earnings
      if (config.compareTarget !== 'none') {
        const targetSalary = COMPARISONS[config.compareTarget];
        const targetRate = annualToMs(targetSalary);
        setComparisonEarnings(elapsedMs * targetRate);
      }
 
      animationFrameId = requestAnimationFrame(tick);
    };
 
    animationFrameId = requestAnimationFrame(tick);
 
    return () => cancelAnimationFrame(animationFrameId);
  }, [config]);
 
  return { currentEarnings, comparisonEarnings };
}
 
// Helper to find the start of the current pay cycle
function getCycleStartTime(config: AppConfig): number {
  const now = new Date();
  // Simplification: Assume monthly starts on the 1st for this snippet
  // In production, this would handle the 'payDay' variable and holiday logic
  return new Date(now.getFullYear(), now.getMonth(), 1).getTime();
}

5. The Component

Finally, we wire it all together. Note how the UI renders based on the configuration, and changing the configuration automatically updates the URL via our hook.

export const PaydayDashboard = () => {
  const [config, setConfig] = useUrlSyncedState();
  const { currentEarnings, comparisonEarnings } = useMoneyTicker(config);
 
  const formatMoney = (amount: number) => 
    new Intl.NumberFormat('en-US', { 
      style: 'currency', 
      currency: config.currency, 
      minimumFractionDigits: 4 
    }).format(amount);
 
  return (
    <div className="p-8 max-w-2xl mx-auto">
      <div className="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
        <h1 className="text-2xl font-bold text-gray-800 mb-6">
          Since the month began, you have earned:
        </h1>
        
        <div className="text-5xl font-mono text-green-600 mb-8">
          {formatMoney(currentEarnings)}
        </div>
 
        {config.compareTarget !== 'none' && (
          <div className="mb-8 p-4 bg-gray-50 rounded-lg">
            <p className="text-sm text-gray-500 mb-1">
              In that same time, {config.compareTarget} earned:
            </p>
            <p className="text-xl font-mono text-red-500">
              {formatMoney(comparisonEarnings)}
            </p>
            <p className="text-xs text-gray-400 mt-2">
              (This might be bad for your mental health)
            </p>
          </div>
        )}
 
        {/* Configuration Controls */}
        <div className="grid grid-cols-2 gap-4 mt-8 pt-8 border-t border-gray-100">
           <div>
             <label className="block text-xs font-bold text-gray-500 uppercase tracking-wide">
               Annual Salary
             </label>
             <input 
               type="number" 
               value={config.salary}
               onChange={(e) => setConfig({...config, salary: Number(e.target.value)})}
               className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
             />
           </div>
           
           <div>
             <label className="block text-xs font-bold text-gray-500 uppercase tracking-wide">
               Compare With
             </label>
             <select
               value={config.compareTarget}
               onChange={(e) => setConfig({...config, compareTarget: e.target.value as any})}
               className="mt-1 block w-full"
             >
               <option value="none">No one (Healthy)</option>
               <option value="musk">Elon Musk (Painful)</option>
             </select>
           </div>
        </div>
        
        <div className="mt-6 text-center">
           <p className="text-sm text-gray-500">
             Share this link! Your configuration is saved in the URL.
           </p>
        </div>
      </div>
    </div>
  );
};

The Psychology of Real-Time Numbers

There is something visceral about seeing your salary break down into seconds.

When you see that a 30-minute meeting cost your company $50 in your time alone, you start to view productivity differently. When you compare your ticker to Elon Musk's and realize his counter is moving faster than your eye can track, it puts scale into perspective in a way that static numbers never can.

But technically, the beauty here is the statelessness.

If I send you a link to my dashboard, I am sending you the application state itself rather than a reference to a database row ID 5492. There is no API call to fail. There is no database to migrate. If I stop paying my hosting bill and move this static HTML file to an S3 bucket or GitHub Pages, the link still works perfectly.

Conclusion

As developers, we often reach for the heavy tools because they feel "professional." Tools like databases and server-side sessions are the hammers we use for every nail.

But for utilities and calculators, the URL is a significantly better persistence layer. It respects the user's privacy (data never leaves their browser) and reduces your infrastructure costs to zero.

Next time you build a small tool, ask yourself: Does this really need a database? or can the state live in the query string?