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

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 just need to send invoices and know when clients see them.
So I built Invox — an open‑source, self‑hosted invoicing tool that does exactly that.
What it does
- Invoices – Create, edit, and send invoices with line items, taxes, and discounts.
- View tracking – Know exactly when your client opens an invoice.
- Recurring – Generate and send invoices on a schedule.
- Follow‑ups – Automated payment reminders.
- PDF export – Clean PDFs your clients can download.
- Dashboard – View revenue, outstanding amounts, and payment trends.
- Templates – Reusable invoice templates for repeat work.
- Banking (optional) – Connect bank accounts via Salt Edge for auto‑matching payments.
- Light & dark themes – Available 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/), each domain feature owns its own 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 use FSD?
- Self‑contained features – Adding a new feature means creating a single directory under
features/. - Easy removal – Deleting a feature is as simple as removing its directory; no stray files remain in unrelated folders.
- Clear ownership – All code related to a domain (API, hooks, UI, validation, constants) lives together, improving discoverability and maintainability.
Strict Layer Boundaries
These aren’t merely guidelines — they’re enforced by ESLint:
src/app/– contains only routing files (page.tsx,layout.tsx,route.ts).- Features – must never import from other features.
shared/– must never import fromfeatures/.src/server/– is the only place that can access Prisma; UI components must not touch the database.
Centralized Environment Variables
A single env.ts file validates all environment variables through Zod:
// src/shared/config/env.ts
import { z } from "zod";
export const env = 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‑only variables throw if accessed on the client, and the app crashes immediately with a clear message when 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)
ESLint Guard
An ESLint rule bans process.env everywhere except this file, forcing the use of the typed env object:
// 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 application can be started with a single command sequence:
# Clone the repository
git clone https://github.com/maksim-pokhiliy/invox.git
cd invox
# Generate a secret for NextAuth and store it in .env
echo "NEXTAUTH_SECRET=$(openssl rand -base64 32)" > .env
# Build and run the containers in detached mode
docker compose up -d
-
docker-compose.yml- Spins up a PostgreSQL instance and the Invox app.
- Handles environment variables, networking, and volume mounting.
-
Dockerfile- Uses a multi‑stage build (
deps → build → run). - Produces a standalone Next.js build, keeping the final image small.
- Uses a multi‑stage build (
-
Prisma migrations
- Run automatically on container startup, ensuring the database schema is up‑to‑date.
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.
Build‑time configuration
The feature flag is injected at build time via next.config.ts:
// next.config.ts
export default defineConfig({
env: {
NEXT_PUBLIC_BANKING_ENABLE: process.env.SALT_EDGE_APP_ID ? "true" : "false",
},
});
When SALT_EDGE_APP_ID is defined, NEXT_PUBLIC_BANKING_ENABLE becomes "true"; otherwise it is "false" and the banking UI is omitted.
Happy coding!
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 – Currently it’s tightly coupled to
Resend. A simple adapter pattern would make it swappable.
Here’s a tidied‑up version of the markdown while keeping the original intent and structure:
## Try it
- **Live demo:** [Insert demo link here]
- **GitHub:** [Insert repository link here]
MIT licensed. If you're a freelancer — I'd love to hear what's missing.
If you're a developer — PRs are welcome. 