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

  1. Predictability — Every route handler tells the same story in the same order
  2. Readability — A junior developer on their first day can trace any bug
  3. Simplicity — Minimise requisite knowledge; one way to do each thing
  4. 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:

  1. Authenticate & authorise — Who is allowed here?
  2. Parse input to local variables — What does this endpoint accept?
  3. Sanitise user input — Strip XSS and other nasties before anything else
  4. Validate user input — Is the input in the correct format?
  5. Enforce business requirements — Is the user allowed to do this specific thing?
  6. Perform business logic — Do the actual work
  7. Return HTTP response — Send data + status code

Here’s what this looks like in a Next.js App Router route handler:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// app/api/users/route.ts

import { NextRequest, NextResponse } from "next/server";
import { requireAuth } from "@/lib/auth";
import { sanitise } from "@/lib/sanitisation";
import { accountService } from "@/services/account-service";
import { ValidationError, UsernameExistsError, EmailExistsError, TermsNotAcceptedError } from "@/lib/errors";

export async function POST(request: NextRequest) {
  // 1. Authenticate & authorise
  // (For account creation, this might be a rate-limit check or captcha verification)
  await requireAuth(request, { allowAnonymous: true, rateLimit: "account-creation" });

  // 2. Parse input to local variables
  const body = await request.json();
  const unsafeUsername = body.username ?? "";
  const unsafeEmailAddress = body.email_address ?? "";
  const unsafeTermsAccepted = body.terms_of_service_accepted ?? null;
  const unsafePassword = body.password ?? "";

  // 3. Sanitise user input
  const sanitisedUsername = sanitise.stripXss(unsafeUsername);
  const sanitisedEmailAddress = sanitise.stripXss(unsafeEmailAddress);
  const sanitisedTermsAccepted = sanitise.toBoolean(unsafeTermsAccepted);

  // 4–6. Validate, enforce business rules, perform logic (in the service)
  try {
    const { authToken } = await accountService.createAccount({
      sanitisedUsername,
      sanitisedEmailAddress,
      unsafePassword,
      sanitisedTermsAccepted,
    });

    // 7. Return HTTP response
    return NextResponse.json({ data: { auth_token: authToken } }, { status: 201 });
  } catch (error) {
    if (error instanceof ValidationError) {
      return NextResponse.json({ errors: error.toResponse() }, { status: 422 });
    }
    if (error instanceof UsernameExistsError) {
      return NextResponse.json({ errors: error.toResponse() }, { status: 409 });
    }
    if (error instanceof EmailExistsError) {
      return NextResponse.json({ errors: error.toResponse() }, { status: 409 });
    }
    if (error instanceof TermsNotAcceptedError) {
      return NextResponse.json({ errors: error.toResponse() }, { status: 422 });
    }
    throw error; // Let 500s propagate to your error monitoring
  }
}

Compare this to the typical Next.js tutorial code:

1
2
3
4
5
6
// The "quick tutorial" version — don't do this
export async function POST(request: NextRequest) {
  const body = await request.json();
  const user = await prisma.user.create({ data: body });
  return NextResponse.json(user);
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// services/account-service.ts

import { db } from "@/lib/db";
import { accountCreationSchema } from "@/lib/validators";
import { communicationService } from "@/services/communication-service";
import { tokenUtils } from "@/lib/token-utils";
import {
  ValidationError,
  UsernameExistsError,
  EmailExistsError,
  TermsNotAcceptedError,
} from "@/lib/errors";

interface CreateAccountInput {
  sanitisedUsername: string;
  sanitisedEmailAddress: string;
  unsafePassword: string;
  sanitisedTermsAccepted: boolean;
}

async function createAccount(input: CreateAccountInput) {
  // Validate input format
  const parseResult = accountCreationSchema.safeParse({
    username: input.sanitisedUsername,
    emailAddress: input.sanitisedEmailAddress,
    password: input.unsafePassword,
    termsAccepted: input.sanitisedTermsAccepted,
  });

  if (!parseResult.success) {
    throw ValidationError.fromZod(parseResult.error);
  }

  // Normalise for uniqueness checks
  const normalisedUsername = input.sanitisedUsername.toLowerCase().trim();
  const normalisedEmail = input.sanitisedEmailAddress.toLowerCase().trim();

  // Enforce business requirements
  const existingUsername = await db.user.findUnique({
    where: { normalisedUsername },
  });
  if (existingUsername) {
    throw new UsernameExistsError();
  }

  const existingEmail = await db.emailAddress.findFirst({
    where: { normalisedEmail, isVerified: true },
  });
  if (existingEmail) {
    throw new EmailExistsError();
  }

  if (!input.sanitisedTermsAccepted) {
    throw new TermsNotAcceptedError();
  }

  // Perform business logic
  const hashedPassword = await hashPassword(input.unsafePassword);

  const userRecord = await db.$transaction(async (tx) => {
    const user = await tx.user.create({
      data: {
        username: input.sanitisedUsername,
        normalisedUsername,
        primaryEmail: normalisedEmail,
        hashedPassword,
      },
    });

    await tx.emailAddress.create({
      data: {
        userId: user.id,
        email: input.sanitisedEmailAddress,
        normalisedEmail,
        isPrimary: true,
        isVerified: false,
      },
    });

    return user;
  });

  // Side effects (outside the transaction)
  await communicationService.sendAccountActivationEmail({ userId: userRecord.id });

  const authToken = await tokenUtils.generateAuthToken(userRecord.id);

  return { userRecord, authToken };
}

export const accountService = { createAccount };

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:

1
2
3
4
5
6
7
async function createAccount(
  input: CreateAccountInput,
  options?: {
    skipEmailVerification?: boolean;  // For admin-created accounts
    createdBy?: string;               // Audit trail
  }
) { ... }

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// lib/sanitisation.ts

import DOMPurify from "isomorphic-dompurify";

function stripXss(unsafeInput: string): string {
  return DOMPurify.sanitize(unsafeInput, { ALLOWED_TAGS: [] });
}

function toBoolean(unsafeInput: unknown): boolean {
  if (typeof unsafeInput === "boolean") return unsafeInput;
  if (unsafeInput === "true") return true;
  if (unsafeInput === "false") return false;
  return false;
}

function toInteger(unsafeInput: unknown): number | null {
  const parsed = Number(unsafeInput);
  return Number.isInteger(parsed) ? parsed : null;
}

export const sanitise = { stripXss, toBoolean, toInteger };

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

1
2
3
4
5
// What's in data? What shape? Who knows.
async function getStuff(id: string): Promise<any> { ... }

// Record<string, unknown> tells you nothing about the shape
function process(config: Record<string, unknown>) { ... }

Good: Specific types that tell a story

1
2
3
4
5
6
7
8
9
interface UserProfile {
  userId: string;
  username: string;
  emailAddress: string;
  dateJoined: string;       // ISO 8601
  profileImageUrl: string | null;
}

async function getUserProfile(userId: string): Promise<UserProfile> { ... }

Rules for TypeScript types:

  • Never use any in application code. Use unknown if you genuinely don’t know the type, then narrow it. Reserve any for library interop only, and add a // eslint-disable-next-line comment 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. UserProfile not UserDataObject. CreateAccountInput not AccountPostBody.
  • Use branded types for IDs to prevent accidentally swapping userId and threadId:
1
2
3
4
type UserId = string & { readonly __brand: "UserId" };
type ThreadId = string & { readonly __brand: "ThreadId" };

// Now the compiler will catch: getUserProfile(someThreadId)
  • Use readonly for data that shouldn’t be mutated:
1
2
3
4
5
interface UserProfile {
  readonly userId: string;
  readonly username: string;
  // ...
}

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 date or datetime — 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.ts open, you’re going to have a terrible day. Name your files descriptively: account-service.ts, not service.ts or (god forbid) index.ts in a services/account/ folder.
  • Greppability — if you search for UserService and 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 createAccount function in account-service.ts”, it knows exactly what you mean. If you say “edit the create method in the Service class” — 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 UserService and UserServiceImpl. If you need an interface, call it UserServiceInterface — or better yet, don’t use classes at all (Rule #9).
  • Don’t have user.ts (model), user.ts (component), and user.ts (test). Use user-model.ts, user-profile-card.tsx, and user-profile-card.test.tsx.
  • Barrel files (index.ts that 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:

1
2
3
4
5
// What's in options? What's required? What's optional?
async function createAccount(options: Record<string, unknown>) { ... }

// Or worse — spreading request body directly
async function createAccount(body: any) { ... }

Good:

1
2
3
4
5
6
7
8
interface CreateAccountInput {
  sanitisedUsername: string;
  sanitisedEmailAddress: string;
  unsafePassword: string;
  sanitisedTermsAccepted: boolean;
}

async function createAccount(input: CreateAccountInput): Promise<CreateAccountResult> { ... }

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 Request objects 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 just any with extra steps.
  • Destructure at the call site, not in the function signature — so readers can see exactly what’s being passed:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Good — explicit about what's being passed
const result = await accountService.createAccount({
  sanitisedUsername,
  sanitisedEmailAddress,
  unsafePassword,
  sanitisedTermsAccepted,
});

// Bad — unclear what's in the spread
const result = await accountService.createAccount({ ...sanitisedBody, unsafePassword });

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:

  1. You start with a simple UserService class
  2. Then you need to inject dependencies, so you add constructor injection
  3. Then someone adds an IUserService interface for “testability”
  4. Then you need a DI container to wire it all together
  5. Then decorators for logging, caching, error handling
  6. Now you have 200 lines of infrastructure for 20 lines of business logic

Just use functions:

1
2
3
4
5
6
7
8
9
// services/account-service.ts

import { db } from "@/lib/db";

async function createAccount(input: CreateAccountInput) { ... }
async function getUserProfile(userId: string) { ... }
async function updateUserProfile(userId: string, input: UpdateProfileInput) { ... }

export const accountService = { createAccount, getUserProfile, updateUserProfile };

The export const accountService = { ... } pattern gives you:

  • NamespacingaccountService.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 Error and need instanceof checks)
  • 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:

  1. Upstream errors — from auth middleware, Next.js internals, etc. Leave them alone.
  2. Validation errors — syntactically invalid input. Return all field errors as a dictionary.
  3. Business requirement errors — valid input but business rules prevent the action. Return the first error.
  4. 500 errors — unexpected bugs. Let them propagate to your error monitoring (Sentry, etc.).

Implementation in TypeScript:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// lib/errors.ts

export class AppError extends Error {
  constructor(
    message: string,
    public readonly statusCode: number,
    public readonly internalErrorCode?: string,
  ) {
    super(message);
    this.name = this.constructor.name;
  }

  toResponse() {
    return {
      display_error: this.message,
      internal_error_code: this.internalErrorCode,
    };
  }
}

export class ValidationError extends AppError {
  constructor(
    public readonly fieldErrors: Record<string, string[]>,
    displayError: string = "",
  ) {
    super(displayError, 422);
  }

  static fromZod(zodError: z.ZodError): ValidationError {
    const fieldErrors: Record<string, string[]> = {};
    for (const issue of zodError.issues) {
      const field = issue.path.join(".");
      if (!fieldErrors[field]) fieldErrors[field] = [];
      fieldErrors[field].push(issue.message);
    }
    return new ValidationError(fieldErrors);
  }

  toResponse() {
    return {
      display_error: this.message,
      field_errors: this.fieldErrors,
    };
  }
}

export class UsernameExistsError extends AppError {
  constructor() {
    super("An account with this username already exists!", 409, "40901");
  }
}

export class EmailExistsError extends AppError {
  constructor() {
    super("An account with this email address already exists!", 409, "40902");
  }
}

export class TermsNotAcceptedError extends AppError {
  constructor() {
    super("You must accept the terms of service.", 422, "42201");
  }
}

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:

  1. React Server Components (RSC) — async components that fetch on the server
  2. Route Handlers — app/api/*/route.ts files
  3. Server Actions — "use server" functions
  4. Client-side fetching — useEffect / React Query / SWR
  5. Middleware — middleware.ts
  6. generateStaticParams + generateMetadata — build-time fetching

Pick a pattern for each concern and document it:

ConcernPatternWhen
Page data loadingReact Server ComponentsDefault for all pages
External APIRoute Handlers (app/api/)REST endpoints consumed by frontend, mobile, or third parties
MutationsServer ActionsForm submissions, state changes from UI
Real-time / pollingClient-side (React Query)Live data, user-triggered refreshes
Auth checksMiddlewareRedirect 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
// tests/account.test.ts

import { POST } from "@/app/api/users/route";
import { NextRequest } from "next/server";
import { db } from "@/lib/db";

function createRequest(body: Record<string, unknown>): NextRequest {
  return new NextRequest("http://localhost:3000/api/users", {
    method: "POST",
    body: JSON.stringify(body),
    headers: { "Content-Type": "application/json" },
  });
}

describe("POST /api/users", () => {
  beforeEach(async () => {
    await db.user.deleteMany();
    await db.emailAddress.deleteMany();
  });

  // --- Validation errors ---

  test("returns 422 when username exceeds 15 characters", async () => {
    const request = createRequest({
      username: "aoeuaoeuaoeuaoeu",
      email_address: "test@example.com",
      password: "hunter2!",
      terms_of_service_accepted: true,
    });

    const response = await POST(request);
    const body = await response.json();

    expect(response.status).toBe(422);
    expect(body.errors.field_errors.username).toContain(
      "Usernames must be less than or equal to 15 characters."
    );

    expect(await db.user.count()).toBe(0);
  });

  // --- Business requirement errors ---

  test("returns 409 when username is already taken", async () => {
    // Arrange — create existing user
    await accountService.createAccount({
      sanitisedUsername: "aoeu",
      sanitisedEmailAddress: "existing@example.com",
      unsafePassword: "hunter2!",
      sanitisedTermsAccepted: true,
    });

    // Act
    const request = createRequest({
      username: "aoeu",
      email_address: "new@example.com",
      password: "hunter2!",
      terms_of_service_accepted: true,
    });

    const response = await POST(request);
    const body = await response.json();

    // Assert
    expect(response.status).toBe(409);
    expect(body.errors.display_error).toBe(
      "An account with this username already exists!"
    );
    expect(body.errors.internal_error_code).toBe("40901");

    expect(await db.user.count()).toBe(1); // Only the original
  });

  // --- Success ---

  test("creates account and returns auth token", async () => {
    const request = createRequest({
      username: "aoeu",
      email_address: "test@example.com",
      password: "hunter2!",
      terms_of_service_accepted: true,
    });

    const response = await POST(request);
    const body = await response.json();

    expect(response.status).toBe(201);
    expect(body.data.auth_token).toBeDefined();

    expect(await db.user.count()).toBe(1);
    expect(await db.emailAddress.count()).toBe(1);
  });
});

Test ordering for every endpoint:

  1. Permissions (auth failures)
  2. Validation errors (one per field, plus multi-field)
  3. Business requirement errors (one per rule)
  4. 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// lib/validators.ts

import { z } from "zod";

export const accountCreationSchema = z.object({
  username: z
    .string()
    .min(1, "Username is required")
    .max(15, "Usernames must be less than or equal to 15 characters.")
    .regex(
      /^[a-zA-Z][a-zA-Z0-9_]*$/,
      "Username must start with a letter and contain only letters, numbers, and underscores."
    ),
  emailAddress: z.string().email("Please enter a valid email address."),
  password: z.string().min(8, "Password must be at least 8 characters."),
  termsAccepted: z.literal(true, {
    errorMap: () => ({ message: "You must accept the terms of service." }),
  }),
});

export type CreateAccountInput = z.infer<typeof accountCreationSchema>;

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 forUse API Routes for
Form submissions in your own UIEndpoints 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.).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// app/api/admin/users/[id]/route.ts

import { requireAuth } from "@/lib/auth";
import { accountService } from "@/services/account-service";

export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  await requireAuth(request, { requiredRole: "admin" });

  await accountService.deactivateAccount(params.id, {
    deactivatedBy: "admin",
    reason: "admin-action",
  });

  return NextResponse.json({ success: true });
}

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.com to 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:

TierExamplesStandard
Core frameworkNext.js, ReactMust be backed by a well-funded company or massive community
ORM / DBPrisma, DrizzleMust have active development, good migration story
Critical infraAuth (NextAuth/Lucia), Payments (Stripe SDK)Must be maintained by the service provider
UtilitiesZod, date-fns, lodash-esMust be stable, well-typed, and unlikely to need updates
Nice-to-havesEverything elseScrutinise 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// ❌ Bad — business logic in the component
function CheckoutButton({ cartItems }) {
  const total = cartItems.reduce((sum, item) => {
    const discount = item.quantity > 5 ? 0.1 : 0;
    return sum + item.price * item.quantity * (1 - discount);
  }, 0);
  const tax = total * 0.08;
  const shipping = total > 100 ? 0 : 9.99;
  // ... 40 more lines of pricing logic
}

// ✅ Good — component calls a service
function CheckoutButton({ cartItems }) {
  const { total, tax, shipping } = checkoutService.calculateOrderTotal(cartItems);
  // Component only handles display
}

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.js hacks 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.ts is parseable; utils/helpers/index.ts is 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 time to 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:

  1. You write the service function interface (types, function signature, JSDoc describing business rules)
  2. AI generates the implementation
  3. You review the implementation for correctness, security, and adherence to your patterns
  4. AI generates the tests
  5. 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// This isn't just validation — it's a machine-readable specification
// of what a valid user account looks like
export const accountCreationSchema = z.object({
  username: z
    .string()
    .min(1)
    .max(15)
    .regex(/^[a-zA-Z][a-zA-Z0-9_]*$/),
  emailAddress: z.string().email(),
  password: z.string().min(8),
  termsAccepted: z.literal(true),
});

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:

  1. Onboarding document for new developers
  2. Context file for AI assistants (point your AI tool at this file)
  3. 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

  1. Every route handler tells a story (7 steps, same order)
  2. Keep business logic in services
  3. Make services the locus of reusability
  4. Always sanitise input, sometimes save raw, always escape output
  5. Keep file structure flat until complexity demands otherwise

Readability

  1. Use TypeScript types properly — they’re your greatest readability asset
  2. Give files, functions, and types unique, descriptive names
  3. Use explicit function parameters, not opaque objects
  4. Prefer functions and plain objects over classes
  5. There are exactly 4 types of errors

Simplicity

  1. Pick one data-fetching pattern per concern and stick with it
  2. Write tests. Not too many. Mostly integration.
  3. Use Zod as your single validation layer
  4. Server Actions complement API routes — they don’t replace them
  5. Write admin functionality as API endpoints

Upgradability

  1. Your app lives until your dependencies die
  2. Keep business logic out of React components
  3. Don’t break core dependencies

AI-Era Rules

  1. Structure code for AI readability (descriptive names, explicit types, self-contained functions)
  2. Use AI for boilerplate, review it for logic
  3. Write executable specifications (Zod schemas, types, tests = machine-readable specs)
  4. Keep a living Architecture Decision Record

Note

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.