The data model comes first, not the routes
It's tempting to start a Next.js project by sketching the App Router folder tree — routes feel like progress. I start with the entities instead: what exists in the system, how they relate, and which invariants must never break (a quotation cannot outlive the client record it points to, a role cannot grant a permission the account tier doesn't allow). Routes are a view onto that model, not the model itself.
This matters more for internal tools and CRM/ERP-style systems than for marketing sites, because the cost of an unclear model compounds — every new module either respects the boundaries or invents its own, and by the tenth module the second kind wins by volume.
Route handlers and Server Components stay thin on purpose
A page.tsx or route.ts file in my projects is almost always a coordinator: fetch, authorise, call one or two functions from lib/, shape the result. The logic itself — validation, business rules, the actual database query — lives in lib/, independent of any particular route.
- It's directly testable without spinning up a request.
- Two routes that need the same operation call the same function instead of drifting apart.
- Moving a page, splitting a route group, or adding a parallel route never touches business logic — only the coordinator changes.
Server Components by default, client boundaries drawn deliberately
I treat "use client" as a boundary I'm choosing to cross, not a default. Most of a typical page — layout, static content, data that doesn't change after load — has no business shipping JavaScript to the browser. The client boundary goes exactly where interactivity actually starts: a form, a toggle, an animated reveal. Pushed any higher up the tree than that, and you end up shipping the whole page's JavaScript for one button.
This is also why colocating a small client component next to the server component that uses it, rather than a generic components/ dump, keeps the boundary visible — you can see at a glance how much of a given page actually needed to be interactive.
Type safety has to survive the network boundary
TypeScript on the frontend and TypeScript on the backend are two separate promises unless something ties them together. On projects with a Next.js frontend and a real API surface, I use tRPC or a generated client so a change to a database column shows up as a compile error in the component that renders it — not as a runtime surprise three weeks after deploy, reported by a user.
For simpler builds, this can be as light as sharing a Zod schema between the API route and the form that submits to it. The point isn't the specific tool — it's refusing to let the same shape be described twice by hand in two places that can silently disagree.
What I deliberately don't add on day one
Early abstraction is the more common failure mode than none at all. A generic repository layer, a state-management library, a design-system package — all of these are correct decisions eventually and premature ones on a first build. I wait for the third real occurrence of a pattern before extracting it, because the first two usually turn out to be different problems wearing the same shape.

