Next.js & MERN for Startup Founders: A Better Software Architecture for SaaS Startups and Consumer Apps
Next.js & MERN for Startup Founders: A Better Software Architecture for SaaS Startups and Consumer Apps
Adapted from “Django for Startup Founders” by Alex Krupp — rewritten for the TypeScript/Next.js ecosystem, aligned with modern practices and AI-assisted development.
Why This Guide Exists
The original Django guide made a compelling case: the ilities that matter for startups are predictability, readability, simplicity, and upgradability — not scalability, not performance, not clever abstractions. That insight is language-agnostic.
But the JavaScript/TypeScript ecosystem has its own failure modes. Where Django teams drown in fat models and serialiser spaghetti, JS teams drown in:
- Prop-drilling vs. global state debates that never end
- 47 different ways to fetch data (client, server, RSC, route handler, middleware, edge function…)
- “Clean architecture” folder structures with 12 layers of abstraction for a CRUD app
- npm packages that mass-produce left-pad incidents
This guide applies the same first-principles thinking to the Next.js (App Router) + TypeScript stack — with MERN (MongoDB/Express/React/Node) patterns where relevant. Every recommendation is grounded in the same question: does this maximise the number of hypotheses my startup can test before we run out of money?
Who This Is For
SaaS startups and consumer apps with:
- Between 10K and 100K lines of TypeScript
- Maintained by 8 or fewer developers (or could be, if well-architected)
- Most complexity in business logic, not algorithms
Why TypeScript + Next.js
TypeScript is, for startups, what Python was a decade ago: the second-best language for everything. You get a single language across frontend, backend, serverless functions, and scripts. Next.js gives you the “batteries included” experience that Django provides — routing, API handlers, middleware, SSR/SSG — without needing to bolt together Express + React + Webpack + a dozen other pieces. And critically, TypeScript’s type system (when used properly) solves many of the readability problems that Python still struggles with.
For the AI-assisted coding era, TypeScript has another advantage: LLMs are dramatically better at generating and reasoning about typed code. Types serve as constraints that keep AI-generated code on the rails. A well-typed codebase is essentially a set of executable specifications that both humans and AI can navigate.
The Four Ilities
- Predictability — Every route handler tells the same story in the same order
- Readability — A junior developer on their first day can trace any bug
- Simplicity — Minimise requisite knowledge; one way to do each thing
- Upgradability — Your app lives until your dependencies die
Predictability
60–80% of the cost of any line of code is incurred as maintenance after that line is written.
The next-best type of code after “code that never gets written” is code that’s utterly predictable. If someone understands how one route handler works, they should understand how every route handler works.
Rule #1: Every Route Handler Should Tell a Story
There are up to seven steps any REST endpoint performs. Always do them in this order:
- Authenticate & authorise — Who is allowed here?
- Parse input to local variables — What does this endpoint accept?
- Sanitise user input — Strip XSS and other nasties before anything else
- Validate user input — Is the input in the correct format?
- Enforce business requirements — Is the user allowed to do this specific thing?
- Perform business logic — Do the actual work
- Return HTTP response — Send data + status code
Here’s what this looks like in a Next.js App Router route handler:
| |
Compare this to the typical Next.js tutorial code:
| |
No authentication. No sanitisation. No validation. No error handling. Raw user input piped straight into the database. You can’t tell who’s allowed to access this, what input it expects, or what happens when things go wrong.
The first version is longer. It’s also dramatically cheaper to maintain.
Rule #2: Keep Business Logic in Services
In the JS/TS ecosystem, there are several places people put business logic:
- In route handlers / API routes (too coupled to HTTP)
- In React Server Components (too coupled to rendering)
- In ORM models / Prisma extensions (same problems as Django fat models)
- In “controller” classes (unnecessary OOP ceremony)
- In service modules ✓
A “service” is just a TypeScript file with a collection of functions that contain all the business logic for some domain of your app. The pattern is:
HTTP Request → Route Handler → Service → Database/External APIs → Service → Route Handler → HTTP Response
Here’s the service from our account creation example:
| |
Why services over other patterns?
- Not in route handlers — because you want to reuse the same logic in API routes, Server Actions, background jobs, admin tools, and CLI scripts.
- Not in React components — because business logic shouldn’t be coupled to a rendering framework that will be rewritten in 3 years.
- Not in ORM models — Prisma, Drizzle, and Mongoose all have extension mechanisms, but mixing business logic with database configuration creates the same mess as Django fat models. When you look at a Prisma schema or a Mongoose model, you should only see database concerns.
- Not in classes — just use functions. (See Rule #9.)
Rule #3: Make Services the Locus of Reusability
Service functions should be called from anywhere the same logic is needed: route handlers, server actions, background jobs (BullMQ, Inngest, etc.), admin dashboards, seed scripts, CLI tools.
accountService.createAccount(...) ← called from API route
accountService.createAccount(...) ← called from admin dashboard
accountService.createAccount(...) ← called from seed script
accountService.createAccount(...) ← called from bulk import job
If you need slight variations for different contexts, add keyword arguments:
| |
Never duplicate business logic. If two route handlers need the same logic, they call the same service function. If a service function in accountService needs communication logic, it calls communicationService — it doesn’t reimplement email sending.
Rule #4: Always Sanitise User Input, Sometimes Save Raw Input, Always Escape Output
The same principles from the Django guide apply exactly:
- Sanitise = filter (remove dangerous content) + escape (encode for safe use)
- Validate = enforce business rules (separate concern from security)
- Always sanitise before validation, business logic, storage, or presentation
In TypeScript/Next.js:
| |
The unsafe prefix convention is just as valuable in TypeScript as in Python. Naming a variable unsafeUsername makes wrong code look wrong — and makes it immediately visible during code review if unsanitised data is being used downstream.
The password exception still applies: passwords are stored as hashes and stripping characters would reduce entropy.
React’s built-in escaping: React automatically escapes content rendered in JSX, which handles the output-escaping side for most frontend contexts. But this doesn’t protect you on the backend — data stored in the database can be accessed by APIs, emails, exports, etc. Always sanitise on input.
Rule #5: Keep Your File Structure Flat Until Complexity Demands Otherwise
The JS ecosystem has an obsession with deeply nested folder structures. New Next.js projects routinely end up looking like this:
src/
modules/
users/
controllers/
UserController.ts
services/
UserService.ts
repositories/
UserRepository.ts
dtos/
CreateUserDto.ts
UpdateUserDto.ts
interfaces/
IUserService.ts
IUserRepository.ts
mappers/
UserMapper.ts
...
12 files for a single CRUD resource. Each file has 10-30 lines. You spend more time navigating between files than actually reading code. It’s enterprise Java cosplay.
For a new project, start flat:
app/
api/
users/route.ts
users/[id]/route.ts
threads/route.ts
checkout/route.ts
...
services/
account-service.ts
communication-service.ts
thread-service.ts
checkout-service.ts
tests/
account.test.ts
communication.test.ts
thread.test.ts
checkout.test.ts
lib/
db.ts
auth.ts
errors.ts
sanitisation.ts
validators.ts
components/
...
When to split: When any single file exceeds ~500 lines, or when you have multiple developers frequently creating merge conflicts in the same file. Then — and only then — split that file into a folder with focused sub-files. The validator file might become validators/account-validators.ts and validators/thread-validators.ts.
Never prematurely introduce “layers” like repositories, DTOs, mappers, or interfaces-for-the-sake-of-interfaces. Each layer is a tax on every developer who needs to trace a request through the system. Layers should be introduced to solve a real problem, not to satisfy an architectural diagram from a conference talk about microservices at Netflix.
Readability
When development velocity grinds to a halt because of “technical debt,” this is almost always just a euphemism for developers not being able to read each other’s code.
Code should be written for the least technical person who might need to work on it. Assume the next reader is a junior developer on their first day who has no idea what the business does.
Rule #6: Types Are Your Greatest Readability Asset — Use Them Properly
This is where TypeScript has a massive structural advantage over Python. In Python, you need naming conventions (user_model_list, username_to_profile_dict) because the language gives you no other way to communicate types at the point of use. TypeScript does — but only if you use it well.
Bad: Overly generic types that communicate nothing
| |
Good: Specific types that tell a story
| |
Rules for TypeScript types:
- Never use
anyin application code. Useunknownif you genuinely don’t know the type, then narrow it. Reserveanyfor library interop only, and add a// eslint-disable-next-linecomment explaining why. - Prefer interfaces for object shapes, type aliases for unions and primitives. This is a soft convention, but it keeps things consistent.
- Name interfaces and types after what they represent, not their technical structure.
UserProfilenotUserDataObject.CreateAccountInputnotAccountPostBody. - Use branded types for IDs to prevent accidentally swapping
userIdandthreadId:
| |
- Use
readonlyfor data that shouldn’t be mutated:
| |
Naming conventions that TypeScript doesn’t solve:
Even with types, some naming conventions are still valuable:
- Booleans: prefix with
is,has,should,can— e.g.,isPublic,hasVerifiedEmail - Date/datetime strings: prefix with
dateordatetime— e.g.,dateCreated,datetimeLastLogin - Lists: use plural noun — e.g.,
userProfiles,threadIds - Maps: use the pattern
xByY— e.g.,userProfileByUserId,threadsByAuthorId - Unsafe input: prefix with
unsafe— e.g.,unsafeUsername - Sanitised input: prefix with
sanitised— e.g.,sanitisedUsername
Rule #7: Give Files, Functions, and Types Unique Names
The same argument from the Django guide applies with even more force in JS/TS, because:
- IDE tabs — if you have 6 files called
index.tsopen, you’re going to have a terrible day. Name your files descriptively:account-service.ts, notservice.tsor (god forbid)index.tsin aservices/account/folder. - Greppability — if you search for
UserServiceand get 3 different classes in 3 different modules, you’ve wasted time untangling which one you need. - AI code generation — LLMs are dramatically more accurate when they can reference unambiguous names. If you tell an AI “edit the
createAccountfunction inaccount-service.ts”, it knows exactly what you mean. If you say “edit thecreatemethod in theServiceclass” — which one?
Specific anti-patterns in JS/TS to avoid:
- Don’t name every main file in a folder
index.ts. Name it after what it contains. - Don’t have both
UserServiceandUserServiceImpl. If you need an interface, call itUserServiceInterface— or better yet, don’t use classes at all (Rule #9). - Don’t have
user.ts(model),user.ts(component), anduser.ts(test). Useuser-model.ts,user-profile-card.tsx, anduser-profile-card.test.tsx. - Barrel files (
index.tsthat re-export everything) are acceptable for package boundaries, but don’t use them within your own app — they obscure where things actually live and can cause circular dependency nightmares.
Rule #8: Use Explicit Function Parameters, Not Opaque Objects
The TypeScript equivalent of Python’s **kwargs problem is the “options bag” that swallows everything:
Bad:
| |
Good:
| |
TypeScript’s type system makes this less painful than Python’s approach — you get autocomplete, type checking, and documentation for free. But you still need to be intentional:
- Define a specific interface for each service function’s input. Don’t reuse
Requestobjects or Prisma types as input types — you want the service to be callable from anywhere, not just HTTP handlers. - Don’t use
Partial<T>for everything. If a field is required, make it required. If it’s optional, mark it optional.Partial<BigInterface>is justanywith extra steps. - Destructure at the call site, not in the function signature — so readers can see exactly what’s being passed:
| |
Rule #9: Prefer Functions and Plain Objects Over Classes
The Django guide’s argument against Python classes applies even more strongly in TypeScript, but for different reasons.
Python classes are dangerous because they’re too dynamic and mutable. TypeScript classes are dangerous because they’re a gateway drug to enterprise Java patterns that add enormous complexity with negligible benefit in a startup context.
Here’s the progression:
- You start with a simple
UserServiceclass - Then you need to inject dependencies, so you add constructor injection
- Then someone adds an
IUserServiceinterface for “testability” - Then you need a DI container to wire it all together
- Then decorators for logging, caching, error handling
- Now you have 200 lines of infrastructure for 20 lines of business logic
Just use functions:
| |
The export const accountService = { ... } pattern gives you:
- Namespacing —
accountService.createAccount()tells you which file to open - Testability — mock individual functions without DI containers
- Simplicity — no
this, no inheritance, no lifecycle hooks, no decorator magic - AI-friendliness — LLMs generate and reason about functions far more reliably than class hierarchies
When classes are appropriate:
- Error classes (they extend
Errorand needinstanceofchecks) - Database models/schemas if your ORM requires them (Mongoose, TypeORM)
- React components that need error boundaries
- Libraries you’re publishing for others to extend
For your application’s business logic? Functions. Always functions.
Rule #10: There Are Exactly 4 Types of Errors
Identical to the Django guide — this is framework-agnostic:
- Upstream errors — from auth middleware, Next.js internals, etc. Leave them alone.
- Validation errors — syntactically invalid input. Return all field errors as a dictionary.
- Business requirement errors — valid input but business rules prevent the action. Return the first error.
- 500 errors — unexpected bugs. Let them propagate to your error monitoring (Sentry, etc.).
Implementation in TypeScript:
| |
The internalErrorCode convention (5-digit, first 3 = HTTP status) is brilliant and works identically in any language. A frontend developer seeing error code 40901 can instantly Cmd+Shift+F the backend to find the exact line.
Simplicity
Minimise the amount of knowledge it takes to productively contribute.
Rule #11: Pick One Data-Fetching Pattern and Stick With It
This is the Next.js equivalent of “URL parameters are a scam.” The JS ecosystem’s biggest readability killer is having twelve ways to do the same thing.
Next.js App Router gives you at least six places to fetch data:
- React Server Components (RSC) —
asynccomponents that fetch on the server - Route Handlers —
app/api/*/route.tsfiles - Server Actions —
"use server"functions - Client-side fetching —
useEffect/ React Query / SWR - Middleware —
middleware.ts generateStaticParams+generateMetadata— build-time fetching
Pick a pattern for each concern and document it:
| Concern | Pattern | When |
|---|---|---|
| Page data loading | React Server Components | Default for all pages |
| External API | Route Handlers (app/api/) | REST endpoints consumed by frontend, mobile, or third parties |
| Mutations | Server Actions | Form submissions, state changes from UI |
| Real-time / polling | Client-side (React Query) | Live data, user-triggered refreshes |
| Auth checks | Middleware | Redirect unauthenticated users |
Then enforce it. If someone adds a useEffect + fetch for data that should be loaded in a Server Component, that’s a code review flag.
The one-pattern-per-concern rule also applies to:
- State management: pick one (React context +
useReducer, or Zustand — not both plus Redux plus Jotai) - Form handling: pick one (React Hook Form, or Conform, or plain controlled inputs — not all three)
- Validation: pick one (Zod everywhere — frontend, backend, API, forms)
Rule #12: Write Tests. Not Too Many. Mostly Integration.
The testing philosophy is identical to the Django guide, with JS-specific tooling:
Integration tests on your API route handlers are your bread and butter:
| |
Test ordering for every endpoint:
- Permissions (auth failures)
- Validation errors (one per field, plus multi-field)
- Business requirement errors (one per rule)
- Success conditions (all happy paths)
Unit tests are specialist tools — use them for:
- Pure functions that transform data (formatters, parsers, normalisers)
- Complex validation logic with many edge cases
- Security-sensitive code
- Utility functions shared across many services
Mock boundaries, not internals: Mock external services (email, payment, etc.) and use a real test database (not mocked Prisma calls). If your tests pass with a mocked database but fail with a real one, your tests are lying to you.
Rule #13: Use Zod as Your Single Validation Layer
The Django guide recommends Marshmallow. In the TS ecosystem, the answer is Zod — and you should use it everywhere:
| |
Why Zod everywhere:
- Single source of truth for validation rules
- Type inference —
z.infer<typeof schema>gives you the TypeScript type for free - Works identically in route handlers, server actions, and client-side forms
- Better error messages than hand-rolled validation
- One thing to learn, one documentation source to read
Rule #14: Server Actions Are Not a Replacement for API Routes
Server Actions ("use server") are great for form mutations. They are not a replacement for API routes. Use them where they shine and ignore the hype for everything else.
| Use Server Actions for | Use API Routes for |
|---|---|
| Form submissions in your own UI | Endpoints consumed by mobile apps |
| Simple mutations (create, update, delete) | Endpoints consumed by third parties |
| Progressive enhancement (works without JS) | Webhooks from external services |
| Complex query endpoints with many parameters | |
| Anything that needs to be tested independently of React |
Critical: Server Actions are tightly coupled to your React component tree. If your startup ever needs a mobile app, a public API, or a third-party integration, you’ll need route handlers anyway. Build on route handlers first, and add server actions as a convenience layer for your own frontend.
Rule #15: Write Admin Functionality as API Endpoints
Same principle as Django — expose admin operations as API routes with admin-only permissions, then reuse those service functions in whatever admin UI you build (custom admin panel, Retool, Superblocks, etc.).
| |
This way:
- Admin logic is tested the same way as all other logic
- You can swap admin UIs without rewriting business logic
- QA can test admin functionality via Postman/Bruno
- You have an audit trail through the same patterns as user-facing code
Upgradability
Rule #16: Your App Lives Until Your Dependencies Die
The JS dependency landscape is significantly more volatile than Python’s. A few survival rules:
Minimise dependencies aggressively:
- Before adding a package, check: Can I write this in 50 lines of TypeScript? If yes, don’t add the package.
- Use
bundlephobia.comto check package size and dependency count. A 2KB utility shouldn’t pull in 50 transitive dependencies. - Prefer packages with zero or few dependencies themselves.
- Check the package’s GitHub: recent commits, active maintainers, TypeScript support, open issue count.
The dependency tiers:
| Tier | Examples | Standard |
|---|---|---|
| Core framework | Next.js, React | Must be backed by a well-funded company or massive community |
| ORM / DB | Prisma, Drizzle | Must have active development, good migration story |
| Critical infra | Auth (NextAuth/Lucia), Payments (Stripe SDK) | Must be maintained by the service provider |
| Utilities | Zod, date-fns, lodash-es | Must be stable, well-typed, and unlikely to need updates |
| Nice-to-haves | Everything else | Scrutinise heavily; write it yourself if possible |
Lock your Node version — use .nvmrc or package.json engines field. A Next.js app built on Node 18 should not silently break when someone runs it on Node 22.
Run npm audit in CI — and actually fix the vulnerabilities, don’t just ignore them.
Rule #17: Keep Business Logic Out of React Components
This is the JS-specific version of “keep logic out of the frontend,” but it’s even more critical because the frontend framework churn is measured in months, not years.
| |
When you need to rewrite your frontend (and you will — from Pages Router to App Router, from App Router to whatever comes next, from React to whatever replaces React), the migration is trivial if components are thin shells around service functions.
The rule of thumb: If you can’t describe what a component does in one sentence that starts with “displays” or “lets the user”, there’s business logic that belongs in a service.
Rule #18: Don’t Break Core Dependencies
Same principle as Django, different examples:
- Don’t use a custom router that bypasses Next.js file-based routing
- Don’t replace React’s rendering with a custom virtual DOM
- Don’t use a Prisma alternative that’s incompatible with Prisma’s migration system
- Don’t eject from Next.js or Create React App unless you’re truly prepared to maintain the build system yourself
- Don’t use
next.config.jshacks that will break on the next minor version
The temptation in JS is especially strong because everything feels replaceable. But replacing a core dependency in a running application is like replacing the engine of a car while driving on the motorway.
AI-Powered Development: New Rules for a New Era
The original Django guide was written for a world where all code was hand-written. We no longer live in that world. Here are additional rules for working effectively with AI coding assistants.
Rule #19: Structure Your Code for AI Readability
AI assistants (Claude, Cursor, Copilot, etc.) work best when:
- File names are descriptive.
account-service.tsis parseable;utils/helpers/index.tsis not. - Functions are self-contained. A function that reads top-to-bottom with explicit parameters and return types can be understood in isolation. A method on a class with inherited state cannot.
- Types are explicit. Every function parameter and return type should be typed. This gives the AI context without needing to read the entire codebase.
- Business rules are in prose comments near the code. AI can use comments like
// Users can only have one verified email at a timeto generate correct logic. - There’s one way to do each thing. If your codebase has three different patterns for fetching data, the AI will randomly pick one (and probably pick the wrong one).
Rule #20: Use AI for Boilerplate, Review It for Logic
The most effective AI-assisted workflow for startups:
- You write the service function interface (types, function signature, JSDoc describing business rules)
- AI generates the implementation
- You review the implementation for correctness, security, and adherence to your patterns
- AI generates the tests
- You review and extend the tests for edge cases the AI missed
This works because:
- AI is excellent at mechanical translation (types → implementation)
- AI is mediocre at understanding business intent
- AI is terrible at knowing when it’s wrong
Never merge AI-generated code without reading every line. The cost of reading AI-generated code is much lower than writing it yourself, but it’s never zero.
Rule #21: Write Executable Specifications, Not Just Types
In an AI-assisted codebase, your Zod schemas, TypeScript interfaces, and test cases serve a dual purpose — they’re specifications that both humans and AI can execute against.
| |
When an AI reads this schema, it knows exactly what constraints to respect when generating code that creates or validates accounts. When a human reads it, they know the same thing. This is the convergence point — code that’s optimised for AI readability is also optimised for human readability.
Rule #22: Keep a Living Architecture Decision Record
Create a docs/architecture.md (or similar) file in your repo that documents:
- Your chosen patterns (data fetching, state management, error handling, etc.)
- Your dependency choices and why you chose them
- Your naming conventions
- Your file structure conventions
- Patterns that are explicitly not used and why
This serves triple duty:
- Onboarding document for new developers
- Context file for AI assistants (point your AI tool at this file)
- Decision log that prevents relitigating resolved debates
Update it whenever a pattern changes. Stale architecture docs are worse than no docs at all.
The Complete Project Structure
For a new Next.js App Router project, here’s what following all these rules looks like:
my-app/
app/
api/
users/route.ts # Account CRUD
users/[id]/route.ts # Single user operations
users/[id]/profile/route.ts # Profile operations
threads/route.ts # Thread CRUD
checkout/route.ts # Checkout flow
admin/
users/route.ts # Admin user management
users/[id]/route.ts # Admin single user ops
(auth)/
login/page.tsx
signup/page.tsx
(app)/
dashboard/page.tsx
threads/[id]/page.tsx
layout.tsx
error.tsx
not-found.tsx
services/
account-service.ts
communication-service.ts
thread-service.ts
checkout-service.ts
lib/
db.ts # Prisma client / DB connection
auth.ts # Auth helpers
errors.ts # Error classes
sanitisation.ts # Input sanitisation
validators.ts # Zod schemas (split when large)
token-utils.ts # JWT / auth token utilities
components/
ui/ # Generic UI components
features/ # Feature-specific components
tests/
account.test.ts
communication.test.ts
thread.test.ts
checkout.test.ts
docs/
architecture.md # Living architecture decisions
middleware.ts # Next.js middleware (auth, redirects)
next.config.ts
package.json
tsconfig.json
Why Make Coding Easier?
The original guide’s conclusion applies word-for-word, but the stakes are higher in the AI era.
Velocity — Your developers (and their AI assistants) are wasting time navigating inconsistent patterns, reading irrelevant code, and debugging avoidable complexity. Every unnecessary abstraction layer, every redundant state management library, every clever-but-unreadable one-liner is a direct tax on shipping speed.
Optionality — The startup that can test 20 hypotheses while their competitor tests 5 will win. Simple, readable code is the subway extension — zero cost today, enormous value if you need to pivot tomorrow.
Security — The biggest security gains come from making wrong code look wrong. The unsafe prefix convention, the predictable route handler structure, the integration tests that assert exact response shapes — these are all systems that make security mistakes visible and catchable.
AI Leverage — This is the new frontier. A well-structured, consistently-patterned codebase amplifies AI assistance by 10x. A messy codebase turns AI into a liability — it’ll happily generate code that follows whichever of your six conflicting patterns it encounters first, making the mess worse.
Diversity — When your codebase requires 6 months of deep TypeScript experience to contribute, you can only hire from a tiny pool. When it requires “can read English and has completed a React tutorial,” you can hire from the entire world — and evaluate people on the skills that actually matter for your business.
Quick Reference: Rules at a Glance
Predictability
- Every route handler tells a story (7 steps, same order)
- Keep business logic in services
- Make services the locus of reusability
- Always sanitise input, sometimes save raw, always escape output
- Keep file structure flat until complexity demands otherwise
Readability
- Use TypeScript types properly — they’re your greatest readability asset
- Give files, functions, and types unique, descriptive names
- Use explicit function parameters, not opaque objects
- Prefer functions and plain objects over classes
- There are exactly 4 types of errors
Simplicity
- Pick one data-fetching pattern per concern and stick with it
- Write tests. Not too many. Mostly integration.
- Use Zod as your single validation layer
- Server Actions complement API routes — they don’t replace them
- Write admin functionality as API endpoints
Upgradability
- Your app lives until your dependencies die
- Keep business logic out of React components
- Don’t break core dependencies
AI-Era Rules
- Structure code for AI readability (descriptive names, explicit types, self-contained functions)
- Use AI for boilerplate, review it for logic
- Write executable specifications (Zod schemas, types, tests = machine-readable specs)
- Keep a living Architecture Decision Record
- Another great article on this topic is Tao of Node - Design, Architecture & Best Practices by Alex Kondov.
This guide is opinionated. That’s the point. Having opinions and enforcing them consistently is infinitely better than having no opinions and letting each developer do whatever they fancy. The specific opinions matter less than the consistency.
If you disagree with any of these rules, that’s fine — replace them with your own, document the replacement, and enforce it. The worst possible outcome is having no rules at all.
Part of the "Startup Founders" series
- Part 1: Next.js & MERN for Startup Founders: A Better Software Architecture for SaaS Startups and Consumer Apps