A full-stack expense tracking application built with Next.js 14, TypeScript, Google Sheets, and PostgreSQL. Expenses are stored in Google Sheets with automatic monthly organization, while PostgreSQL serves as an optional performance cache.
- Google Sheets Integration - All expenses automatically saved to Google Sheets
- Monthly Organization - Expenses organized into monthly sheets (e.g., "June 2025", "September 2026")
- Automatic Totals - Each month sheet includes automatic SUM formulas for total spending
- Create, read, update, and delete expenses
- Categorize expenses with custom categories
- View expense history with filtering
- Responsive UI with dark mode support
- Type-safe API with Zod validation
- PostgreSQL database for performance caching (optional)
- Frontend: Next.js 14 (App Router), React, TypeScript, Tailwind CSS
- Backend: Next.js API Routes, Server Actions
- Primary Data Store: Google Sheets API
- Cache Database: PostgreSQL with Prisma ORM (optional for performance)
- Validation: Zod
- Development: ESLint, Prettier, Docker
- Node.js 18+
- Docker and Docker Compose (for PostgreSQL database)
- npm, yarn, or pnpm
- Google Cloud Project with Sheets API enabled
- Google Service Account with access to your spreadsheet
- Go to Google Cloud Console
- Create a new project or select an existing one
- Enable the Google Sheets API:
- Go to "APIs & Services" > "Library"
- Search for "Google Sheets API"
- Click "Enable"
- In the Google Cloud Console, go to "APIs & Services" > "Credentials"
- Click "Create Credentials" at the top
- Select "Service Account" from the dropdown
- Fill in the service account details on the first page:
- Service account name:
expense-entry-service(or any name you prefer) - Service account ID: This will auto-fill based on the name
- Description (optional): "Service account for expense tracking app"
- Click "Create and Continue"
- Service account name:
- On the "Grant this service account access to project" page:
- DO NOT select any role - just click "Continue"
- We'll grant access directly to the spreadsheet instead of at the project level
- On the "Grant users access to this service account" page:
- Leave everything blank and click "Done"
- You'll see your new service account in the list. Click on it to open the details
- Go to the "Keys" tab
- Click "Add Key" > "Create new key"
- Choose "JSON" format and click "Create"
- A JSON file will download automatically - this file contains your credentials
- The file will be named something like
your-project-xxxxx.json - Keep this file secure - it gives access to your spreadsheet
- You'll copy the contents of this file into your
.envfile later
- The file will be named something like
-
Go to Google Sheets
-
Click the "+ Blank" button to create a new blank spreadsheet
-
At the top, click "Untitled spreadsheet" and name it "Expense Tracker" (or whatever you prefer)
-
Copy the Spreadsheet ID from the URL in your browser:
- The URL format is:
https://docs.google.com/spreadsheets/d/{SPREADSHEET_ID}/edit - Example: If your URL is
https://docs.google.com/spreadsheets/d/1ABC-xyz123/edit, the ID is1ABC-xyz123 - Copy this ID - you'll need it for the
.envfile
- The URL format is:
-
Share the spreadsheet with your service account:
Finding the Service Account Email:
- Open the JSON file you downloaded in Step 1.2 with any text editor
- Look for the
client_emailfield - it will look something like:"client_email": "expense-entry-service@your-project-123456.iam.gserviceaccount.com"
- Copy this entire email address
Sharing the Sheet:
- In your Google Sheet, click the "Share" button in the top-right corner
- In the "Add people and groups" field, paste the service account email you just copied
- Make sure the permission dropdown says "Editor" (not "Viewer" or "Commenter")
- IMPORTANT: Uncheck the box that says "Notify people" - the service account won't receive emails
- Click "Share" or "Done"
You should now see the service account email listed under "People with access" with "Editor" permission.
Why Editor permission? The app needs to:
- Create new month sheets (e.g., "June 2025")
- Add/update/delete expense rows
- Add formulas for totals
Security Note: Only this service account can access the sheet via the API. Your personal Google account still has full control and can revoke access anytime by removing the service account from the share settings.
git clone <repository-url>
cd expense-entrynpm installcp .env.example .envEdit the .env file and add your Google Sheets credentials:
Your downloaded JSON file looks like this (with real values instead of ...):
{
"type": "service_account",
"project_id": "your-project-123456",
"private_key_id": "abc123...",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIB...\n-----END PRIVATE KEY-----\n",
"client_email": "expense-entry-service@your-project-123456.iam.gserviceaccount.com",
"client_id": "123456789...",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/..."
}- Get the Spreadsheet ID from Step 1.3 (from the URL)
- Get the JSON credentials - open the JSON file with a text editor and copy the entire contents
- Edit your
.envfile to look like this:
# Database (optional - used for caching/performance)
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/expense_entry?schema=public"
# Google Sheets Integration (REQUIRED)
GOOGLE_SHEETS_SPREADSHEET_ID="1ABC-xyz123"
GOOGLE_SERVICE_ACCOUNT_CREDENTIALS='{"type":"service_account","project_id":"your-project-123456","private_key_id":"abc123...","private_key":"-----BEGIN PRIVATE KEY-----\nMIIEvQIB...\n-----END PRIVATE KEY-----\n","client_email":"expense-entry-service@your-project-123456.iam.gserviceaccount.com","client_id":"123456789...","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url":"https://www.googleapis.com/robot/v1/metadata/x509/..."}'
# Next.js
NEXT_PUBLIC_APP_URL="http://localhost:3000"Important:
- For
GOOGLE_SHEETS_SPREADSHEET_ID: Paste the Spreadsheet ID from Step 1.3 - For
GOOGLE_SERVICE_ACCOUNT_CREDENTIALS:- Copy the entire contents of your JSON file
- Remove all newlines and formatting - it should be one single line
- Keep it wrapped in single quotes
'...' - The
\ncharacters in the private key are important - keep them as-is
npm run docker:up# Generate Prisma client
npm run db:generate
# Push schema to database
npm run db:push
# Seed the database with default categories
npm run db:seednpm run devOpen http://localhost:3000 in your browser.
- Creating expenses: Expenses are saved to both PostgreSQL and Google Sheets
- Google Sheets organization: Each month gets its own sheet (e.g., "June 2025", "September 2026")
- Automatic totals: Each month sheet includes a TOTAL row with a SUM formula
- Reading expenses: Data is read from PostgreSQL for performance
- Sync: On page load, the app checks Google Sheets for any manually-added expenses and syncs them to PostgreSQL
- Editing in Sheets: You can manually add/edit expenses in Google Sheets, and they'll be synced to the app on next page load
Solution: Make sure your .env file has the GOOGLE_SERVICE_ACCOUNT_CREDENTIALS variable set with the full JSON content on a single line.
Possible causes:
-
Service account not shared with the sheet
- Open your Google Sheet
- Click "Share" and verify the service account email is listed with "Editor" permission
- The email should look like:
your-service@your-project.iam.gserviceaccount.com
-
Wrong spreadsheet ID
- Check your
.envfile has the correctGOOGLE_SHEETS_SPREADSHEET_ID - The ID should be from the URL:
https://docs.google.com/spreadsheets/d/{THIS_PART}/edit
- Check your
-
Google Sheets API not enabled
- Go to Google Cloud Console > "APIs & Services" > "Library"
- Search for "Google Sheets API" and make sure it's enabled
Solution:
- Open your downloaded JSON file in a text editor
- Copy the entire contents (including the outer
{and}) - Make sure it's all on one line in the
.envfile - Wrap it in single quotes
'...'not double quotes - Example:
GOOGLE_SERVICE_ACCOUNT_CREDENTIALS='{"type":"service_account",...}'
- Start the dev server:
npm run dev - Open the app and create a test expense
- Open your Google Sheet - you should see:
- A new sheet tab created for the current month (e.g., "January 2025")
- Header row: ID, Date, Description, Amount, Category, Category Color
- Total row: "TOTAL:" with a SUM formula
- Your test expense in row 3
If you've lost the service account email:
- Open the JSON credentials file you downloaded
- Look for the
client_emailfield - Or go to Google Cloud Console > "IAM & Admin" > "Service Accounts"
- Your service account will be listed there with its email
npm run dev- Start the development servernpm run build- Build for productionnpm start- Start production servernpm run lint- Run ESLintnpm run format- Format code with Prettiernpm run type-check- Run TypeScript type checking
npm run db:generate- Generate Prisma clientnpm run db:push- Push schema changes to databasenpm run db:migrate- Create and run migrationsnpm run db:studio- Open Prisma Studio (database GUI)npm run db:seed- Seed database with default data
npm run docker:up- Start Docker containersnpm run docker:down- Stop Docker containersnpm run docker:logs- View Docker logs
npm test- Run testsnpm run test:watch- Run tests in watch modenpm run test:coverage- Run tests with coverage
expense-entry/
├── prisma/
│ ├── schema.prisma # Database schema
│ └── seed.ts # Database seeding script
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── api/ # API routes
│ │ ├── expenses/ # Expense pages
│ │ ├── layout.tsx # Root layout
│ │ ├── page.tsx # Home page
│ │ └── globals.css # Global styles
│ └── lib/ # Shared utilities
│ ├── prisma.ts # Prisma client singleton
│ ├── utils.ts # Utility functions
│ └── validations/ # Zod schemas
├── docker-compose.yml # Docker configuration
├── next.config.js # Next.js configuration
├── tailwind.config.ts # Tailwind configuration
└── tsconfig.json # TypeScript configuration
id: Unique identifieramount: Expense amount (Float)description: Expense descriptiondate: Date of expensecategoryId: Reference to Categoryreceipt: Optional receipt URLnotes: Optional notescreatedAt: Creation timestampupdatedAt: Update timestamp
id: Unique identifiername: Category name (unique)color: Optional color hex codecreatedAt: Creation timestampupdatedAt: Update timestamp
GET /api/expenses- List all expenses (with optional filters)- Query params:
?sync=trueto force sync from Google Sheets before fetching startDate,endDate,categoryIdfor filtering
- Query params:
POST /api/expenses- Create a new expense (writes to both PostgreSQL and Google Sheets)GET /api/expenses/[id]- Get a specific expensePATCH /api/expenses/[id]- Update an expense (updates both PostgreSQL and Google Sheets)DELETE /api/expenses/[id]- Delete an expense (deletes from both PostgreSQL and Google Sheets)
GET /api/categories- List all categoriesPOST /api/categories- Create a new category
GET /api/sync- Check if sync is neededPOST /api/sync- Manually trigger sync from Google Sheets to PostgreSQL
- User creates/updates/deletes an expense via the UI
- API route validates the data with Zod
- Data is written to PostgreSQL (fast, local cache)
- Data is written to Google Sheets (source of truth)
- Response returned to user
- User requests expenses via the UI
- Data is read from PostgreSQL (fast)
- Data returned to user
- On page load, SyncChecker component runs
- Checks if Google Sheets has more expenses than PostgreSQL
- If yes, fetches all expenses from Google Sheets
- For each expense in Sheets not in PostgreSQL:
- Creates the category if it doesn't exist
- Inserts the expense into PostgreSQL
- User notification shows how many expenses were synced
This architecture ensures:
- Google Sheets is the source of truth - you can manually edit expenses there
- PostgreSQL provides fast reads - no API rate limits or latency
- Automatic sync keeps data consistent - manual edits in Sheets appear in the app
- Make changes to the code
- Run
npm run formatto format code - Run
npm run lintto check for linting issues - Run
npm run type-checkto check TypeScript types - Run
npm testto run tests - Commit your changes
MIT