A full-stack invoice and customer management application built with Next.js, React 19, and TypeScript. The project uses Server Actions for mutations, Drizzle ORM for type-safe database access, Auth.js for authentication, and Vitest for component testing — with a focus on accessibility, reusable abstractions, and modern React patterns like useActionState and useFormStatus.
A working deployment showcasing authentication, CRUD flows, validation, flash notifications, loading states, and accessibility features is available at acme.ygbstudio.net.
Demo Credentials:
- Email:
user@nextmail.com - Password:
123456
This project originated from the official Next.js App Router tutorial. After completing the guided portion, the repository became a deliberate engineering exercise — an opportunity to work through the kinds of problems that tutorials intentionally skip: migrating a data layer, designing reusable components, writing meaningful tests, building accessible interactions, and maintaining a codebase as it evolves through incremental refactoring. The commit history reflects that iterative process rather than a single large implementation.
The Next.js tutorial teaches the App Router's fundamentals well, but it stops short of the engineering concerns that arise in real applications — testing, architectural refactoring, accessibility, reusable component design, and data layer maintainability.
This repository exists to explore those concerns. It is not intended as a production application, but as a working codebase where each technical decision was made to solve a specific problem and can be examined in context.
| Layer | Technology |
|---|---|
| Framework | Next.js (App Router), React 19 |
| Language | TypeScript 5.7 (strict) |
| Styling | Tailwind CSS |
| Database | PostgreSQL via Neon |
| ORM | Drizzle ORM |
| Authentication | Auth.js v5 (Credentials provider) |
| Validation | Zod |
| Testing | Vitest, React Testing Library, jsdom |
The following areas represent independent engineering work performed after completing the guided tutorial:
- 75 tests across 10 files — validating form interaction, async submission, pending UI states, accessibility attributes, toast lifecycle, dialog keyboard behavior, and pagination boundaries
- ORM migration — replaced raw SQL with Drizzle ORM; schema, queries, and TypeScript types are co-derived and verified at compile time
- Customer CRUD — the tutorial's customer page is read-only; all create, edit, and delete workflows are original
- React 19 patterns —
useActionStatefor form state management,useFormStatusfor pending UI, and Server Actions as the mutation layer - 25+ reusable components and 3 custom hooks in
app/ui/common/— table primitives, buttons, dialogs, error blocks, form shells, pagination — consumed by both invoices and customers - Flash notifications — cookie-based system using UUID event IDs to show exactly one toast per mutation, surviving redirects and re-renders
- Accessibility as design — ARIA live regions, keyboard-navigable dialogs, screen-reader labels, and semantic navigation throughout
- Type-level schema enforcement — compile-time
Expect<Equal<>>assertions verify that hand-written types match Drizzle's inferred schema types
- Credential-based authentication with session-protected routes
- Dashboard overview with revenue chart, summary cards, and latest invoices
- Full CRUD for invoices: create, edit, delete with search and pagination
- Full CRUD for customers: create, edit, delete with search and pagination
- Flash notifications for every successful or failed mutation
- Confirmation dialog before destructive actions
- Loading skeletons, error boundaries, and accessible breadcrumbs
- Configurable latency simulation for demonstrating Suspense loading states
graph LR
A[Browser] --> B[Server Components]
A --> C[Server Actions]
C --> D[Zod Validation]
D --> E[Drizzle ORM]
B --> E
E --> F[(PostgreSQL)]
C --> G[Flash Cookies]
G --> B
sequenceDiagram
participant Browser
participant Middleware as Middleware (Edge)
participant Server as Next.js Server (Node)
participant DB as PostgreSQL
Browser->>Middleware: Request /dashboard/*
Middleware->>Middleware: auth.config.ts — authorized()
alt Not authenticated
Middleware-->>Browser: Redirect → /login
else Authenticated
Middleware->>Server: Forward request
Server-->>Browser: Render Server Component
end
Browser->>Server: POST /login (credentials form)
Server->>Server: Zod validates input
Server->>DB: SELECT user by email
DB-->>Server: User record
Server->>Server: bcryptjs.compare(password, hash)
Server-->>Browser: Session cookie
Server Components fetch data directly via Drizzle. Mutations flow through Server Actions that validate input with Zod, write to the database, set a flash cookie, and redirect. Client Components manage interactivity — form state via useActionState, pending indicators via useFormStatus, toast visibility, dialog toggling — without a dedicated state management library.
Authentication is split across two files by design: auth.config.ts runs in the Edge runtime (no Node.js APIs, no database calls) and gates every dashboard request at the middleware layer. The full auth.ts runs only in the Node.js runtime when credentials are actually submitted.
- Composition over duplication — shared UI primitives are extracted once and consumed by both feature domains
- Test observable behavior, not implementation — the suite validates what users see and interact with, not internal component state
- Validate at the server boundary — every Server Action validates its input with Zod before touching the database
- Accessibility is a first-class design constraint — ARIA attributes and keyboard behavior are part of component design from the start, not applied retroactively
- Use TypeScript to enforce architecture — compile-time type assertions catch schema drift before it becomes a runtime error
The tutorial queries the database with raw SQL template literals. This project replaces the entire data layer with Drizzle ORM, introducing a declarative schema (app/lib/db/schema.ts) that serves as the single source of truth for table structure, foreign keys, and constraints. TypeScript types are derived from the schema at compile time, and app/lib/definitions.ts uses Expect<Equal<>> assertions to verify that hand-written types stay in sync with Drizzle's inferred types.
Auth.js v5 handles session management with a Credentials provider. Input is validated with Zod before querying the database; passwords are verified with bcryptjs. Route protection runs at the Edge middleware layer, keeping the database out of the request path for unauthenticated visitors. Sign-in and sign-out are exposed as Server Actions callable from forms.
The tutorial leaves the customer page read-only. All mutation functionality is original: routes under app/dashboard/customers/, Zod-validated Server Actions with field-level error returns, auto-generated avatars on creation, and a guard that prevents deletion of customers with associated invoices.
The tutorial provides no feedback after mutations. This project adds a cookie-based flash system: after a Server Action completes, three short-lived cookies (variant, message, UUID event ID) are written. On the next render, TrackedToastNotification reads them and activates once per event ID, ensuring each notification appears exactly once regardless of re-renders.
Shared primitives live in app/ui/common/ rather than being duplicated per feature. Table components are consumed identically by invoices and customers. Generic button, dialog, form shell, hook, and error components are composed into feature-specific views, keeping domain components focused on their own logic.
Delete operations open a modal dialog rather than executing immediately. The dialog defaults keyboard focus to the cancel button, closes on Escape or backdrop click, shows a spinner during the pending state, and triggers the Server Action through a wrapping form on confirmation.
- Path aliases (
@lib/*,@ui/*,@app/*) reduce import depth across the project - Demo latency mode introduces configurable artificial delay to make Suspense skeletons visible during demonstrations
- Prettier with Tailwind plugin enforces consistent class ordering
Accessibility is treated as part of component design rather than a separate compliance pass:
- Validation errors are announced via ARIA live regions without interrupting screen-reader speech
- Breadcrumbs use semantic
<nav>witharia-current="page"on the active item - The confirmation dialog uses
role="dialog"witharia-modal, labeled regions, and keyboard focus defaulting to the safe action - Toast notifications distinguish between
role="status"(success) androle="alert"(error) - Destructive and edit action buttons carry screen-reader-only labels
Escapecloses the dialog;Enteractivates the focused action
The project includes 75 tests across 10 files with 84.2% statement coverage, 86.8% branch coverage, 89.5% function coverage, and 86.8% line coverage (V8).
| Metric | Coverage |
|---|---|
| Statements | 84.2% |
| Branches | 86.8% |
| Functions | 89.5% |
| Lines | 86.8% |
Coverage is intentionally focused on the application's behavior rather than achieving an arbitrary percentage. The highest coverage is concentrated on reusable UI components, custom hooks, accessibility behavior, form interactions, asynchronous state transitions, and pending UI—areas where regressions would have the greatest impact on users. Utility helpers are tested selectively based on their complexity and business value rather than solely to increase coverage metrics.
The test suite uses Vitest, React Testing Library, and jsdom, focusing on observable user behavior rather than implementation details. The suite validates what a user would see and do: interacting with form fields, submitting with valid and invalid data, observing validation errors, watching buttons enter pending states during async operations, dismissing toasts, and navigating dialogs with keyboard and mouse. It also verifies that accessibility attributes — live regions, ARIA roles, screen-reader announcements — are correctly applied where they matter.
pnpm test # watch mode
pnpm test:coverage # v8 coverage report- Migrating from raw SQL to Drizzle reinforced the value of compile-time guarantees. Once the schema becomes the single source of truth, an entire class of bugs — column name mismatches, incorrect field types, missing constraints — becomes impossible to ship silently.
- Testing asynchronous UI and pending states required a deeper understanding of React's rendering model. Fake timers,
act()boundaries, and the order of state updates matter in ways that are easy to get wrong. - Accessibility is easier when designed into reusable components than when added afterward. A dialog that handles
Escape, focus management, and ARIA attributes once is significantly simpler than applying those same concerns to every individual instance. - Server Actions require different testing strategies than traditional client-side APIs. Mocking at the module level is straightforward, but testing actions themselves in isolation — without a database — is a gap the current suite does not fully address.
- Small, composable primitives reduce duplication more effectively than well-documented large components. The table primitives in
app/ui/common/are simple enough that developers can read them in seconds, yet flexible enough to cover both invoices and customers without modification.
app/
├── api/auth/ — Auth.js route handlers
├── dashboard/
│ ├── (overview)/ — Dashboard home (route group)
│ ├── customers/ — Customer list, create, edit routes
│ └── invoices/ — Invoice list, create, edit routes
├── lib/
│ ├── actions/ — Server Actions (auth, cookies, customers, invoices)
│ ├── db/ — Drizzle schema and relations
│ ├── data.ts — Data-fetching functions
│ ├── definitions.ts — Types, Zod schemas, compile-time assertions
│ └── utils.ts — Formatting, pagination, avatar, latency helpers
├── login/
├── seed/
└── ui/
├── common/ — Shared primitives
├── customers/ — Customer-specific forms and table
├── dashboard/ — Charts and cards
└── invoices/ — Invoice-specific forms and table
auth.config.ts — NextAuth middleware config (Edge)
auth.ts — NextAuth config with database access
drizzle.config.ts — Drizzle Kit configuration
vitest.config.ts
vitest.setup.ts
Prerequisites: Node.js 18+, pnpm, a PostgreSQL database (Neon or compatible)
git clone https://github.com/Urbine/acme-dashboard.git
cd acme-dashboard
pnpm install
cp .env.example .env| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | Pooled PostgreSQL connection string |
DATABASE_URL_UNPOOLED |
Yes | Direct connection string for Drizzle Kit |
AUTH_SECRET |
Yes | Session signing key — generate with openssl rand -base64 32 |
SEED_TOKEN |
Yes | Token used to protect seed route - generate with openssl rand -hex 32 |
DEMO_LATENCY |
No | Set to "true" for artificial latency in Suspense demos |
DEMO_MODE |
No | Set to "true" to enable schema destruction for demo seeding |
pnpm dev # development server (Turbopack)
pnpm build # production build
pnpm lint # ESLint
pnpm format # Prettier
pnpm test # Vitest watch
pnpm test:coverage # coverage reportSeed the database by visiting http://localhost:3000/seed?token=<SEED_TOKEN> after starting the dev server.
Note: Without a matching
SEED_TOKEN, the route responds with HTTP403.
While extending and testing this project, a concurrency issue was identified in the official tutorial's database seed implementation. The investigation led to an upstream contribution:
vercel/next-learn#1323 — fix: resolve duplicate extension race condition in seed route
The tutorial's seed route ran CREATE EXTENSION IF NOT EXISTS "uuid-ossp" inside a sql.begin transaction block. Under parallel execution, concurrent transactions raced to write to pg_catalog.pg_extension, triggering a PostgreSQL unique_violation error (code 23505). The fix moves extension creation outside the transaction so it runs once before seeding begins.
- E2E tests — the current suite covers units and components; end-to-end user flows are not yet covered
- Server Action tests — actions are tested only indirectly through form component mocks
- Image upload — customer avatars are auto-generated; file upload would require object storage
- Role-based access — the auth model could support multiple permission levels
- Optimistic updates — mutations currently wait for the server; optimistic patterns would reduce perceived latency
This project is licensed under the MIT License. See the LICENSE file for details.
- Tutorial foundation: Next.js App Router course by Vercel
- Avatar generation: ui-avatars.com
- Icons: Heroicons
The repository documents an incremental engineering process: architectural decisions, refactorings, testing additions, and accessibility improvements introduced over time to solve concrete problems. Each section of the codebase reflects a specific engineering concern, and the commit history traces how the project arrived at its current shape.
