// Blog

How I built a lead tracker in 90 minutes

A small build, end to end. One HTML file, no framework, no backend — a working lead tracker for a service business that runs in the browser and writes nothing to a server. Plus the parts where I would do it differently if you actually plan to keep it.

// Author :: AlphaPrism#tutorial#internal-tools#youtube-companion

This is the written companion to Video 7 on the channel. If you would rather watch me type the thing live, the video is embedded at the bottom. If you would rather read code, this is the post.

The build: a lead tracker for a service business. Form on top, a list of leads in the middle, status filters across the top, basic search, and a way to mark a lead as "called", "booked", or "dead." Everything stored in localStorage. Total time at the keyboard, with no preparation: 1 hour 27 minutes.

A working internal tool that is good enough to actually use. No deploy. No login. No database. One HTML file.

Why localStorage is the right answer for this build

If you are a one-person shop, the cost of a real backend is rarely worth the time. You do not need to share data with anyone, you do not need to access it from another device, and you do not need it to survive your laptop being run over. You need a way to stop losing leads in your text messages.

localStorage gives you:

  • Persistent state across browser refreshes.
  • No dependencies. No npm install. No deploy.
  • Backup is "right-click → save as" on the HTML file plus a copy of the JSON blob.

The 90-minute version skips authentication, multi-device sync, and any server. The 6-month version, if you keep using it, gets a Supabase row store under the same UI in another 90 minutes. We will get to that.

The whole thing in three files-worth of code

I prefer one HTML file for builds like this — copy/paste-able, no toolchain. Here is the entire data layer:

type LeadStatus = 'new' | 'called' | 'booked' | 'dead'
 
type Lead = {
  id: string
  name: string
  phone: string
  source: string
  status: LeadStatus
  note?: string
  createdAt: number
}
 
const KEY = 'alphaprism.leads.v1'
 
function loadLeads(): Lead[] {
  try {
    return JSON.parse(localStorage.getItem(KEY) ?? '[]') as Lead[]
  } catch {
    return []
  }
}
 
function saveLeads(leads: Lead[]): void {
  localStorage.setItem(KEY, JSON.stringify(leads))
}

Three observations on this snippet that are easy to miss:

  • The KEY is versioned (v1). When you change the schema later, you bump to v2 and write a one-time migration on load. Saves a ton of pain.
  • JSON.parse is wrapped in a try/catch. If your storage gets corrupted (browser crash, manual edit, etc.), you fall back to an empty list rather than throwing.
  • Lead is typed in a way that maps cleanly to a database row if you ever swap the storage backend.

The UI is a single form + a filterable list. The most interesting piece is the filter, because it is what makes the tool actually usable past 20 leads:

function filterLeads(leads: Lead[], status: LeadStatus | 'all', query: string): Lead[] {
  const q = query.trim().toLowerCase()
  return leads.filter((lead) => {
    if (status !== 'all' && lead.status !== status) return false
    if (!q) return true
    return (
      lead.name.toLowerCase().includes(q) ||
      lead.phone.includes(q) ||
      lead.source.toLowerCase().includes(q)
    )
  })
}

That is it. No fuzzy match, no debouncing yet, just a substring filter over three fields. If you find yourself wanting more, you have probably outgrown localStorage and the right next step is a real database.

The bits I'd do differently if you plan to keep it

The 90-minute version is good enough to use tomorrow. But three sharp edges show up around the two-week mark:

  1. No multi-device sync. If you do anything from your phone, the laptop's localStorage doesn't know. The right fix is a Supabase row store + an anonymous device id + an "this is my dataset" passphrase. About 90 more minutes.
  2. No backup. Browsers occasionally clear localStorage on their own (disk full, profile reset, "Clear browsing data"). You will not get a warning. A nightly "download backup" button that pushes the current JSON to your Downloads folder costs about 20 minutes and saves the dataset from extinction.
  3. No undo. It is a leads list, not a spreadsheet — but the moment a customer says "you marked me dead by mistake," you want an undo. A small actions[] ring buffer in storage solves it. About 15 minutes.

The full source

The build ships as index.html — open in a browser, you have a lead tracker. No build step, no deps. Companion video walks through every line.

Want a real one built for your business?

The 90-minute build is fun. But if you are running an actual operation, you probably want the version with multi-device sync, role-based access, undo, a real backup story, and a backend that doesn't depend on a single browser profile staying alive.

That is the same kind of system we build for clients in two to three weeks, hosted on the client's own accounts.

// Companion video
[ TL ][ TR ][ BL ][ BR ]

Stop clicking. Start automating.

15 minutes. No pitch deck. No sales theater. Just a real conversation about what's slow, what's broken, and what we'd automate first.

Book a 15-min Intro Call
Prefer email? sergio@alphaprism.net