An open-source expense tracker template — a mobile-first PWA backed by Google Sheets. Fork it and make it yours.
DuitLog is a ready-to-fork expense tracker template built as a mobile-first PWA. It logs daily expenses in under 10 seconds, using Google Sheets as the single source of truth — the app is purely a fast input surface, while all analysis (pivots, charts, dashboards) lives in Sheets.
Originally built for a couple tracking shared expenses in Indonesia, but designed to be easily customized for any use case. See Make It Yours to adapt it to your needs.
flowchart LR
A["Phone (PWA)"] -->|Form POST| B["React Router v7\nServer Action"]
A -->|Offline| E["IndexedDB Queue"]
E -->|Background Sync\nor online event| F["/api/sync"]
F -->|Validate with Zod| C
B -->|Validate with Zod| C["Google Sheets API v4"]
C --> D["Google Spreadsheet\n(Monthly tabs: YYYY-MM)"]
D -->|loader read| B
B -->|JSON response| A
- Sub-10-second expense logging from phone home screen
- Mobile-first UI with numeric keyboard, smart defaults, one-hand operation
- Google Sheets as canonical datastore (no secondary database)
- 9 categories (Food, Transport, Groceries, Utilities, Health, Entertainment, Shopping, Education, Other)
- 3 payment methods (Cash, BCA Debit, QRIS)
- Multi-source tracking (Danny, Dewi, Together) with cookie persistence
- Category and payment method selection via tap-friendly pill buttons
- Expense history with source filtering and month navigation
- Installable PWA (Android + iOS) with SW update notifications
- Simple passcode authentication with 30-day sessions
- Haptic feedback on successful save
DuitLog works offline with a two-layer sync system:
- IndexedDB queue — When offline (or server unreachable), expenses are saved locally to IndexedDB. The submit button changes to "Save Offline" and an amber banner indicates offline status.
- SW Background Sync — On supported browsers, the service worker registers a
sync-expensesevent. When connectivity returns, the SW automatically syncs queued expenses to/api/sync. - Client-side fallback sync — On browsers without
SyncManager(e.g. iOS Safari), the app listens foronlineevents and syncs pending expenses from the main thread. - Web Lock coordination — Both sync paths acquire a
navigator.locksWeb Lock (duitlog-sync) to prevent concurrent sync and double-submission. - Offline history — Previously loaded history entries are cached in
localStorageand shown when offline. - Navigation fallback — Failed navigation requests fall back to a cached
/offlinepage. - Static asset caching — JS, CSS, images, and manifest use a stale-while-revalidate strategy.
DuitLog is designed to be forked and customized. Here's what to change:
Edit CATEGORIES, METHODS, and SOURCES arrays to match your needs. Zod validation schemas in app/lib/validation.ts derive from these arrays, so they update automatically.
Copy .env.example and fill in your own values:
GOOGLE_SERVICE_ACCOUNT_EMAIL/GOOGLE_PRIVATE_KEY— your GCP service accountGOOGLE_SPREADSHEET_ID— your Google SheetAUTH_PASSCODE— your access codeSESSION_SECRET— a random string for cookie signing
Your sheet tab headers (row 1) should match the values in constants.ts — particularly the Source and Method columns.
- Edit
public/manifest.webmanifestto change the app name, colors, etc. - Replace
public/icon-192.png,public/icon-512.png, andpublic/apple-touch-icon.pngwith your own icons.
| Layer | Technology |
|---|---|
| Framework | React Router v7 (Framework Mode) |
| Language | TypeScript |
| Styling | Tailwind CSS v4 |
| Data Layer | Google Sheets API v4 (Service Account) |
| Validation | Zod |
| Deployment | Vercel (Serverless) |
- Node.js >= 20
- npm
- A Google Cloud project with the Sheets API enabled
- A Service Account with a JSON key (see Google Sheets Setup)
# 1. Fork the repo on GitHub, then clone your fork
git clone https://github.com/<your-username>/duit-log.git
cd duit-log
# 2. Install dependencies
npm install
# 3. Copy the environment template and fill in your values
cp .env.example .env
# 4. Start the dev server
npm run devOpen http://localhost:5173 in your browser.
Follow these steps to configure Google Sheets as your datastore:
-
Create a GCP project Go to console.cloud.google.com and create a new project (or use an existing one).
-
Enable the Google Sheets API In your project, navigate to APIs & Services > Library, search for "Google Sheets API", and enable it.
-
Create a Service Account Go to APIs & Services > Credentials > Create Credentials > Service Account. Give it a name (e.g.,
duitlog-sheets), then click Done. -
Download the JSON key On the Service Account detail page, go to the Keys tab, click Add Key > Create new key, and choose JSON. Save the downloaded file securely.
-
Extract credentials from the JSON key Open the JSON file and copy:
client_email→GOOGLE_SERVICE_ACCOUNT_EMAILprivate_key→GOOGLE_PRIVATE_KEY
-
Create a Google Spreadsheet Create a new spreadsheet in Google Sheets.
-
Create monthly sheet tabs
The app uses monthly tabs with the naming pattern
YYYY-MM(e.g.,2025-01,2025-07,2026-03). You must manually create a tab for each month you want to track. The app does not auto-create tabs — if a tab is missing, the API returns a 400 error.Each tab needs the following header row (row 1, columns A–G):
Timestamp Item Category Amount Method Date Source Column Description Example A - Timestamp Auto-generated server timestamp (Asia/Jakarta) 3/9/2026 14:05:32B - Item Expense description Nasi gorengC - Category One of: Food, Transport, Groceries, Utilities, Health, Entertainment, Shopping, Education, Other FoodD - Amount Amount in IDR (numeric) 25000E - Method One of: Cash, BCA Debit, QRIS QRISF - Date User-selected date ( M/D/YYYY)3/9/2026G - Source One of: Danny, Dewi, Together DannyTip: Create tabs for the next few months in advance so the app is always ready. The app reads all tabs matching the
YYYY-MMpattern and shows the most recent first. -
Share the spreadsheet with the Service Account Click Share, paste the
client_emailfrom step 5, and grant Editor access. -
Copy the Spreadsheet ID From the spreadsheet URL
https://docs.google.com/spreadsheets/d/SPREADSHEET_ID/edit, copy theSPREADSHEET_IDportion. -
Fill in the
.envfile Populate all values in your.envfile using the credentials and IDs from the steps above.
Note about
GOOGLE_PRIVATE_KEY: The key from the JSON file contains real newlines. In the.envfile, store it as a single line with literal\ncharacters, wrapped in double quotes (e.g.,"-----BEGIN PRIVATE KEY-----\nMIIEv....\n-----END PRIVATE KEY-----\n"). The app parses this at runtime with.replace(/\\n/g, "\n").
duit-log/
├── app/
│ ├── root.tsx # Root layout, SW registration, bottom nav
│ ├── routes/
│ │ ├── _index.tsx # "/" — Add Expense form + action
│ │ ├── history.tsx # "/history" — Expense history with filters
│ │ ├── login.tsx # "/login" — Passcode entry
│ │ ├── offline.tsx # "/offline" — Offline fallback page
│ │ └── api.sync.tsx # "/api/sync" — Offline sync endpoint
│ ├── lib/
│ │ ├── sheets.server.ts # Google Sheets API client
│ │ ├── auth.server.ts # Session/cookie helpers
│ │ ├── cookies.server.ts # Month & source cookie persistence
│ │ ├── month.server.ts # Month resolution & network error detection
│ │ ├── logger.server.ts # Structured JSON logging
│ │ ├── constants.ts # Categories, methods, sources
│ │ ├── validation.ts # Zod schemas
│ │ ├── offline-queue.ts # IndexedDB queue for pending expenses
│ │ ├── sync.ts # Client-side sync logic
│ │ └── types.ts # Shared types
│ └── components/
│ ├── expense-form.tsx # Main expense input form
│ └── expense-card.tsx # Single expense entry card
├── public/
│ ├── manifest.webmanifest # PWA manifest
│ ├── sw.js # Service worker (cache + background sync)
│ ├── icon-192.png
│ ├── icon-512.png
│ └── apple-touch-icon.png
├── .env.example # Environment variable template
├── package.json
├── tsconfig.json
├── vite.config.ts
└── react-router.config.ts
| Command | Description |
|---|---|
npm run dev |
Start development server |
npm run build |
Production build |
npm run start |
Start production server locally |
npm run typecheck |
Run TypeScript type checking |
The @react-router/node adapter handles the serverless function configuration.
DuitLog is feature-complete as a template. These are not planned features, but ideas if you want to extend it:
- Today's spending total widget
- Category/method management from the UI
- Auto sheet tab creation
- Receipt photo upload via Google Drive
- Monthly summary views
- Google Sign-In for per-user auth
This project is licensed under the MIT License. See the LICENSE file for details.