DEV Community

Cover image for I built an open-source invoicing app with Next.js 16 — here's the architecture
Maksim Pokhiliy
Maksim Pokhiliy

Posted on • Edited on

I built an open-source invoicing app with Next.js 16 — here's the architecture

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.

Live demo | GitHub

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
Email 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
Enter fullscreen mode Exit fullscreen mode

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 from features/
  • 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(),
  // ...
});
Enter fullscreen mode Exit fullscreen mode

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.",
      },
    ],
  },
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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" : "",
},
Enter fullscreen mode Exit fullscreen mode

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)