As a freelancer, I've tried Wave, Zoho Invoice, and a bunch of others. They all had the same problem: too much. I don't need accounting, payroll, or inventory management. I need to send invoices and know when clients see them.
So I built GetPaid — an open-source, self-hosted invoicing tool that does exactly that.
What it does
- Invoices — create, edit, and send with line items, taxes, discounts
- View tracking — know exactly when your client opens an invoice
- Recurring — invoices that generate and send on a schedule
- Follow-ups — automated payment reminders
- PDF export — clean PDFs your clients can download
- Dashboard — revenue, outstanding amounts, payment trends
- Templates — reusable invoice templates for repeat work
- Banking (optional) — connect bank accounts via Salt Edge for auto-matching payments
- Light & dark themes out of the box
Tech stack
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router, standalone output) |
| UI | MUI 7 (Material UI) |
| Language | TypeScript (strict mode) |
| Database | PostgreSQL 16 + Prisma ORM |
| Auth | NextAuth (Auth.js) |
| Validation | Zod 4 |
| Data fetching | React Query |
| Forms | React Hook Form |
| Resend | |
| Charts | Recharts |
| Deployment | Docker (multi-stage build) |
Architecture: Feature-Sliced Design
The project follows Feature-Sliced Design (FSD). Instead of grouping files by type (components/, hooks/, utils/), every domain feature owns its full vertical slice:
src/
├── app/ # Next.js routing only — pages, layouts, API routes
├── features/ # Domain slices
│ ├── invoices/
│ │ ├── api/ # fetch functions
│ │ ├── hooks/ # React Query hooks
│ │ ├── components/ # UI
│ │ ├── schemas/ # Zod validation
│ │ └── constants/
│ ├── clients/
│ ├── recurring/
│ ├── settings/
│ ├── banking/
│ └── dashboard/
├── shared/ # Cross-feature code (UI kit, config, utils)
├── server/ # Server-side services — sole Prisma consumer
└── providers/ # React context, theme
Why FSD? When I add a new feature, everything lives in one directory. When I delete a feature, I delete one directory. No scattered files across components/, hooks/, services/.
Strict layer boundaries
These aren't guidelines — they're enforced by ESLint:
-
src/app/contains only routing files (page.tsx,layout.tsx,route.ts) - Features never import from other features
-
shared/never imports fromfeatures/ - Only
src/server/can access Prisma — UI components can't touch the database
Centralized environment variables
One pattern I'm particularly happy with: a single env.ts file that validates all environment variables through Zod.
// src/shared/config/env.ts
import { z } from "zod";
const serverSchema = z.object({
DATABASE_URL: z.string().min(1),
NEXTAUTH_SECRET: z.string().min(1),
APP_URL: z.string().min(1).default("http://localhost:3000"),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
RESEND_API_KEY: z.string().min(1).optional(),
// ...
});
The validated env is exported as a lazy Proxy singleton. Server variables throw if accessed on the client. The app crashes immediately with a clear message if a required variable is missing:
Error: Invalid server environment variables:
DATABASE_URL: String must contain at least 1 character(s)
NEXTAUTH_SECRET: String must contain at least 1 character(s)
An ESLint rule bans process.env everywhere except this one file:
// eslint.config.mjs
{
files: ["src/**/*.ts", "src/**/*.tsx"],
ignores: ["src/shared/config/env.ts"],
rules: {
"no-restricted-syntax": [
"error",
{
selector: "MemberExpression[object.name='process'][property.name='env']",
message: "Use `env` from '@app/shared/config/env' instead of process.env.",
},
],
},
}
No more typos in env variable names. No more missing variables discovered at runtime in production.
Self-hosting with Docker
The entire app runs with a single command:
git clone https://github.com/maksim-pokhiliy/getpaid.git
cd getpaid
echo "NEXTAUTH_SECRET=$(openssl rand -base64 32)" > .env
docker compose up -d
The docker-compose.yml spins up PostgreSQL and the app. The Dockerfile uses a multi-stage build (deps → build → run) with Next.js standalone output, keeping the final image small. Prisma migrations run automatically on startup.
Optional banking integration
Banking is entirely optional. If you set SALT_EDGE_APP_ID and SALT_EDGE_SECRET, the banking tab appears in settings and you get automatic payment matching. If you don't — the UI adapts and hides everything banking-related. No dead buttons, no error messages.
This is handled at build time through next.config.ts:
env: {
NEXT_PUBLIC_BANKING_ENABLED: process.env.SALT_EDGE_APP_ID ? "true" : "",
},
What I'd do differently
- Tests. There are none. For an MVP this was fine, but before accepting contributions I need at least integration tests for the API layer.
- i18n. The app is English-only. For a tool targeting freelancers globally, localization should have been considered from the start.
- Email provider abstraction. Right now it's tightly coupled to Resend. A simple adapter pattern would make it swappable.
Try it
Live demo: https://getpaid-community.vercel.app/
GitHub: https://github.com/maksim-pokhiliy/getpaid
MIT licensed. If you're a freelancer — I'd love to hear what's missing. If you're a developer — PRs are welcome.
Top comments (0)