One invoice. Four tools. Thirty minutes.
That was my process before I built Freelancer Tracker. I was billing $85/hr but spending 2 hours a month copy-pasting hours from Toggl into a spreadsheet to generate an invoice nobody would pay for another 30 days. The economics didn't add up.
So I built the tool.
Freelancer Tracker handles everything in one place — clients, projects, time entries, invoices, expenses, and reports. No tab-switching. No manual math. No forgotten invoices.
This is a complete breakdown of how I built it, every real decision I made, and the edge cases I hit along the way.
The Problem
Here's the setup most freelancers are actually running:
- Toggl for time tracking — but completely disconnected from invoices
- Google Sheets for invoices — manually updated every time
- Another spreadsheet for expenses — that nobody remembers to update
- Mental math for cash flow — the worst tool for the job
No single source of truth. You finish a project and spend 30 minutes hunting through tabs just to send one invoice.
Tech Stack
| Layer | Technology |
|---|
| Framework | Next.js 16 (App Router), React 19, TypeScript 5 |
| Styling | Tailwind CSS 4, shadcn/ui (New York style) |
| Auth | Clerk v7 + Svix webhooks |
| Database | Supabase (PostgreSQL) |
| Tables | TanStack Table v8 |
| Charts | Recharts v3 |
| Validation | Zod v4 |
| Export | xlsx library + custom CSV formatter |
I chose tools I'd genuinely enjoy using. Nothing I'd regret six months in.
Database Design
The schema is simple on purpose. Everything hangs off a user and flows down:
users
└── clients
├── projects
│ ├── time_entries (hours, date, billable flag)
│ └── invoices
│ ├── invoice_items
│ └── payments
└── expenses
Each level belongs to the one above it. Querying is predictable. No surprises.
Architecture Decisions
Server Components First
Data-heavy pages fetch directly from Supabase on the server. No loading spinners. No extra round trips. The page arrives ready.
No skeleton screens, no flicker, no client-side fetch waterfall.
One Admin Client, Scoped by User
A single server-side Supabase admin client bypasses RLS — but every query is manually scoped with .eq('user_id', userId). The userId always comes from Clerk's auth() on the server, never from the request body, so it cannot be spoofed.
Pagination by Default, Full Export on Demand
The table only loads what the user can actually see. Hitting Export fires a separate query with no range limit — simple, fast, and the UI never slows down.
Zod on Every API Route
All schemas live in one file — lib/schemas.ts. Every route uses the same parseBody() helper. Bad data gets caught before it ever touches the database. If the shape changes, it changes in one place.
Features Worth Highlighting
Auto Overdue Detection
On every invoices page load, any sent invoice past its due date automatically flips to overdue. No cron job. No manual check.
The status lifecycle:
draft → sent → paid
↘ overdue (auto on page load)
→ cancelled
Public Share Page
Every project gets a secure 64-character share token generated via crypto.randomBytes(32). The public route at /share/[token] queries by token only — no login required. The token is the auth. Clients get a live project view without needing an account.
Invoice Builder
Line items, tax rate, auto-calculated totals. Everything is computed at render time — the database stores only raw values. Status flows from draft → sent → paid with automatic overdue detection layered on top.
Reports Page
Date range filter, four stat cards, and four charts: earnings by month, earnings by client, expenses by category, and billable vs non-billable hours.
Challenges & Solutions
Clerk Doesn't Integrate with Supabase RLS Natively
Supabase RLS expects Supabase Auth JWTs. Clerk issues its own. Rather than configure a custom JWT template (fragile if it breaks), I skip RLS and manually scope every query. Svix webhooks keep the users table in sync with Clerk.
The real edge case: a user can sign in via Clerk before the webhook fires and the DB row exists. I handle this by upserting on the first authenticated request if the row is missing — so there is never a "user not found" crash.
Sort Injection via URL Params
The table sort column comes from a URL param. I validated it against an allowlist before using it in a query — never trust user input directly in an .order() call. Unrecognised values fall back to created_at.
Export vs Display Queries
The same data needs two different query shapes — paginated for the table, unbounded for export. Keeping these as separate functions from day one meant zero refactoring later. Worth the five minutes upfront.
Key Takeaways
- Server Components reduce client-side JS and let you write less code — not a trade-off
- Manual
user_id scoping on every query is tedious but gives full, auditable control
- A single Zod schema file as source of truth pays off from day one
- Build for export early — retrofitting means touching every query
- Auto overdue detection on page load beats a cron job at this scale
- Webhook delivery delay is real — always handle the race between auth and the first DB write