This document explains the design decisions behind EventFlow : why things are structured the way they are, how the layers interact, and how to evolve the project.
- Overview
- Directory Layout Philosophy
- Routing : App Router vs Pages Router
- State Management : Context and Hooks
- API Layer : Route Handlers
- Authentication Flow
- Middleware : Route Protection
- Data Layer : JSON Files
- Migrating to a Database
- Input Validation with Zod
- Rate Limiting
- Component Library : shadcn/ui
EventFlow is a monolithic Next.js application. There is no separate backend service : the API routes live inside the same codebase as the React pages. This makes the project self-contained and easy to run, while still enforcing a clear boundary between the server-side API and the client-side UI.
Browser
|
| HTTP request
v
Next.js (edge middleware)
|
| If request targets a protected route:
| -> verify JWT from cookie
| -> redirect to /login if invalid
|
v
App Router
|
+-- /app/page.js -> React pages (client-side rendering)
+-- /app/api/*/route.jsx -> REST API handlers (server-side only)
The src/ directory is divided by concern, not by feature. This means all contexts live in src/contexts/, all hooks in src/hooks/, all API routes in src/app/api/. This is the standard Next.js App Router layout and makes it straightforward to locate any file.
The alternative (feature-based layout, e.g. src/events/, src/users/) works well for very large codebases. For a project of this scope, concern-based layout keeps navigation simple.
This project uses the Next.js App Router (introduced in Next.js 13, stable from 14). The App Router uses file-system-based routing inside the app/ directory, where each folder becomes a route segment and page.jsx renders the page at that route.
API routes are defined as route.jsx files inside app/api/. Each file exports named functions (GET, POST, PATCH, DELETE) that correspond to HTTP methods. This is the standard for Next.js route handlers.
React state is managed with the Context API. There is one context per data domain.
| Context | Manages |
|---|---|
EventsContext |
Events array, loading state, add/remove event |
UsersContext |
Users array, loading state, add/remove/update user |
InscriptionsContext |
Registrations array, loading state, add/remove registration |
CategoriesContext |
Categories array, loading state |
LoginContext |
Authenticated user, login, logout |
Each context fetches its data from the corresponding API route on mount. All mutations (create, delete, update) go through the API and then update the local state on success, keeping the UI in sync with the server.
Custom hooks are thin interfaces over these contexts. There are two types :
-
Pass-through hooks :
useEvents()simply callsuseContext(EventsContext). They exist so components never import context objects directly, making future refactors easier. -
Derived hooks :
useEvent(id)anduseEventsFiltered()consume the context and compute derived data withuseMemo. They encapsulate logic that would otherwise be duplicated across components.
All contexts are provided at the root of the application in src/app/providers.jsx, which is rendered inside the root layout.
Each API route follows the same structure :
- Validate the request body with a Zod schema (for mutating methods)
- Check authentication from the JWT cookie (for protected endpoints)
- Read the relevant JSON file
- Perform the operation
- Write the updated data back to the file
- Return a typed JSON response with the correct HTTP status code
Error responses follow a consistent shape : { message: string }. They never include stack traces, file paths, or internal identifiers.
The authentication system uses two building blocks : bcrypt for passwords and JWT for sessions.
When a user submits the login form, the API reads the user record by email, then calls bcrypt.compare(submittedPassword, storedHash). This comparison is timing-safe and cannot be shortcut by string comparison tricks. If the comparison fails, the API returns 401 with a generic message that does not indicate whether the email or the password was wrong.
On successful login, the API calls jwt.sign({ userId, email }, JWT_SECRET, { expiresIn }) to create a signed token, then sets it as a cookie with these flags :
| Flag | Value | Reason |
|---|---|---|
HttpOnly |
true | The token is not accessible from JavaScript at all |
Secure |
true in production | The cookie is only sent over HTTPS |
SameSite |
Strict | The cookie is not sent with cross-site requests |
Path |
/ | The cookie is sent with every request |
MaxAge |
JWT expiry in seconds | The browser deletes the cookie when it expires |
The logout endpoint calls cookies().delete('token') server-side, which sets the cookie to an empty value with MaxAge=0. This forces the browser to discard it immediately.
middleware.js at the project root runs on every request that matches the configured matcher. It reads the JWT cookie, verifies the signature and expiry using the shared src/lib/auth.js utility, and either lets the request proceed or redirects to /login.
The middleware protects all routes except the public ones listed in the matcher exclusion pattern. Public routes are : /login, /api/login, /api/logout, /api/events (GET), /api/categories (GET).
The middleware runs at the edge (before any page or API route handler), which means unauthorized requests are rejected before any application code executes. This is significantly more secure than checking authentication inside each individual route handler.
All data is stored in JSON files inside src/data/. Each API route reads and writes its corresponding file using Node.js fs.promises. The files are pretty-printed with 4-space indentation, making them human-readable and easy to inspect or edit directly.
This design has clear trade-offs.
Advantages :
- Zero setup : no database server to install or configure
- Fully portable : the entire data layer is just files on disk
- Human-readable : any editor can inspect the current state of the data
- No migration scripts needed for schema changes in development
Limitations :
- Not safe for concurrent writes in production under high load
- Not suitable for multi-instance (clustered) deployments
- No query language : all filtering happens in JavaScript after reading the full file
- File size grows unbounded without manual cleanup
For a personal project, a teaching context, or a low-traffic deployment, these trade-offs are acceptable. For anything else, see the next section.
The data access code is isolated to the API route files. Migrating to a database requires changes in only those files, not in any React component, hook, or context.
The migration process for each route is :
- Install your database client (e.g.
npm install @prisma/clientfor Prisma, ornpm install pgfor PostgreSQL) - Define your schema
- Replace the
fs.readFile+JSON.parseblock with a database read query - Replace the
fs.writeFile+JSON.stringifyblock with a database write query - Remove the
filePathconstant at the top of the file
No other files need to change. The React components and hooks interact only with the API routes, not with the data layer directly.
| Use case | Recommended option |
|---|---|
| Simple hosted deployment | PlanetScale (MySQL), Neon (PostgreSQL) |
| Self-hosted | PostgreSQL with Prisma ORM |
| Serverless with edge runtime | Turso (SQLite), Cloudflare D1 |
| Document-style data | MongoDB Atlas |
Every API route that accepts a request body (POST, PATCH) validates that body against a Zod schema before doing anything else. If validation fails, the route returns 400 with the Zod error message.
Zod schemas serve as the single source of truth for what a valid request body looks like. They enforce types, required fields, string length limits, and format constraints (e.g. valid email format, date string format). This prevents invalid or malformed data from ever reaching the file system.
The login endpoint uses an in-memory rate limiter. It tracks request counts per IP address over a configurable time window. When a client exceeds the maximum number of attempts within the window, the endpoint returns 429 Too Many Requests.
The implementation is in src/lib/rateLimit.js. It uses a plain JavaScript Map as the store. This is sufficient for single-instance deployments. For multi-instance deployments, replace the Map with a Redis-backed store (e.g. using @upstash/ratelimit). The function signature and return value remain the same, so no calling code needs to change.
EventFlow uses shadcn/ui as its component library. Unlike traditional component libraries that are installed as a dependency, shadcn/ui components are copied directly into src/components/ui/. This means the component source code is part of the project and can be modified freely.
The underlying primitives come from Radix UI, which provides fully accessible, unstyled base components. Tailwind CSS v4 is used for all styling. The components.json file at the project root configures the shadcn/ui CLI for adding new components.