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

Published: (February 9, 2026 at 03:06 AM EST)
5 min read
Source: Dev.to

Source: Dev.to

Source: Dev.to

Cover image for “I built an open‑source invoicing app with Next.js 16 — here's the architecture”

Maksim Pokhiliy

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.

Live demo | GitHub

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

LayerChoice
FrameworkNext.js 16 (App Router, standalone output)
UIMUI 7 (Material UI)
LanguageTypeScript (strict mode)
DatabasePostgreSQL 16 + Prisma ORM
AuthNextAuth (Auth.js)
ValidationZod 4
Data fetchingReact Query
FormsReact Hook Form
EmailResend
ChartsRecharts
DeploymentDocker (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 from features/.
  • 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.
  • 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.
0 views
Back to Blog

Related posts

Read more »

Prisma + MongoDB “Hello World”

Prisma + MongoDB “Hello World” on Docker Prisma is an ORM Object‑Relational Mapper. With MongoDB it works as an Object Document Mapper, mapping collections to...

Signal Forms in Angular 21

Angular 21 Signal Forms – A New Mental Model For years, Angular forms have meant one thing: FormGroup, FormControl, valueChanges, and a tree of AbstractControl...