A reliable TypeScript with Next.js setup starts with clear boundaries: strict compiler settings, predictable folders, shared domain types, and validation at every external data boundary. This checklist shows how to organize a project, move data safely between server and client code, and review the stack before an upgrade.
Overview
Next.js gives a TypeScript application several execution environments to account for. Code may run on the server, in a browser, during a build, or inside a route handler. A type that is safe in one environment may be inappropriate in another. For example, a server-only database client should not be imported into a client component, while browser APIs should not be assumed to exist in server-rendered code.
The most useful TypeScript best practice for a Next.js project is to make these boundaries visible in the project structure and enforce them with configuration. TypeScript can check the shape of values at compile time, but it cannot verify data received from an API, form submission, cookie, or environment variable while the application is running. Type-safe data fetching therefore combines static types with runtime validation.
A practical setup has four layers:
- Configuration: strict compiler options, predictable module resolution, and scripts that run type checking separately from bundling.
- Boundaries: a clear distinction between server-only modules, client components, route handlers, and reusable UI components.
- Domain contracts: shared types for concepts such as users, products, permissions, and API responses, without coupling every component to a transport format.
- Runtime checks: validation for untrusted data before it enters application logic.
For a broader folder discussion, see the TypeScript project structure guide. The goal is not to create the largest possible architecture. It is to make the next change easy to locate and difficult to apply in the wrong layer.
Checklist by scenario
Starting a new Next.js TypeScript project
- Confirm that the project is using the intended routing model and document that choice for contributors.
- Keep the generated TypeScript support files that Next.js expects, and avoid editing generated declarations directly.
- Enable strict checking rather than postponing type errors until the codebase is large. A typical starting point includes
"strict": true, with any exceptions documented in the configuration. - Use a small root structure such as
apporpagesfor routes,componentsfor reusable UI,libfor infrastructure and integrations, andtypesor feature-local files for shared contracts. - Decide whether types belong beside the feature that owns them or in a shared domain module. Prefer feature-local types until more than one boundary genuinely needs the same contract.
- Add separate scripts for development, linting, unit tests, and a full type check. A successful production bundle should not be treated as proof that every TypeScript error has been checked.
Do not add a path alias merely to shorten imports. If you use aliases such as @/components or @/lib, verify that the TypeScript compiler, Next.js bundler, test runner, and any standalone runtime interpret them consistently. The TypeScript path alias guide covers the places that commonly need alignment.
Organizing a growing application
- Group routes by URL and group business logic by feature or domain. A route should coordinate work; it should not become the only place where validation, database queries, and response mapping live.
- Keep data access behind named functions such as
getAccountByIdorlistInvoices. This gives callers a stable interface if the storage implementation changes. - Separate transport types from domain types when the API shape is not the shape the UI needs. Map fields at the boundary instead of spreading API-specific naming throughout components.
- Mark server-only modules clearly and keep secrets, database clients, and privileged operations out of client-importable files.
- Use discriminated unions for states or events that have distinct valid shapes. A field such as
status: "loading" | "success" | "error"can let TypeScript narrow the related data safely.
For configuration objects, the satisfies operator is often useful: it checks that an object conforms to a required shape while preserving useful literal information. This is particularly helpful for route metadata, feature flags, and typed lookup tables. See the guide to safer object and configuration patterns with satisfies.
Fetching data on the server
When data can be loaded on the server, keep the fetch close to the server-rendered route or a server-side data function. Return the smallest stable view model that the component needs rather than exposing a database record everywhere.
type ProductCard = {
id: string;
name: string;
priceInCents: number;
};
async function getProductCard(id: string): Promise<ProductCard> {
const response = await fetch(`${API_URL}/products/${id}`);
if (!response.ok) {
throw new Error("Unable to load product");
}
const value: unknown = await response.json();
return productCardSchema.parse(value);
}
The important details are the unknown boundary, the explicit error check, and the runtime schema. Do not use a type assertion such as as ProductCard as a substitute for checking untrusted JSON. A schema library can infer a TypeScript type, or you can write a focused validation function when the contract is small. The same principle applies to cookies, query parameters, webhook payloads, and environment variables. For environment configuration, review the type-safe environment variables guide.
Passing data to client components
- Pass serializable, presentation-ready values across the server-client boundary.
- Do not pass database connections, request objects, class instances, or secrets to browser code.
- Keep interactive state in the client component, but keep data ownership and privileged fetching on the server where appropriate.
- Define props from the component's actual needs. Avoid importing a large database or API type when a smaller view type is clearer.
type ProductCardProps = {
product: ProductCard;
onAdd: (productId: string) => void;
};
export function ProductCard({ product, onAdd }: ProductCardProps) {
return (
<button onClick={() => onAdd(product.id)}>
{product.name}
</button>
);
}
In React code, type event handlers according to the element and interaction they represent, and let component props describe the contract. Avoid broad types such as any for convenience; use unknown at uncertain boundaries and narrow it deliberately.
Typing route handlers and API responses
- Validate route parameters before using them in a query.
- Validate request bodies before passing them to business logic.
- Return consistent success and error shapes so callers do not need to guess which fields exist.
- Keep authentication and authorization checks close to the server operation they protect.
- Test malformed input, missing records, and permission failures—not only the successful response.
Types shared by a route handler and a client can document an API contract, but they do not validate requests at runtime. Treat shared types as a development aid and schemas or explicit guards as the runtime enforcement layer.
What to double-check
Compiler and lint configuration
Review tsconfig.json rather than accepting every generated option without understanding it. Check strictness, included files, path aliases, module settings, and whether generated files are excluded or intentionally included. If the project uses ESLint with TypeScript-aware rules, ensure the lint configuration points at the correct project files and does not make editor feedback unnecessarily slow. The ESLint and TypeScript setup guide provides a useful review checklist.
Server and client imports
Trace imports from every file marked for client execution. A client component can pull server-only code into the client indirectly through a shared utility. Split modules when necessary: one file can contain pure formatting or types, while another contains database access or secret-dependent logic.
Type declarations and runtime behavior
Check whether a type reflects reality. Optional properties, nullable values, empty arrays, pagination fields, and error responses should be represented honestly. Then check the runtime behavior separately. A compile-time type cannot guarantee that a remote service sends the expected JSON, that a date is a valid date, or that a user has permission to view a record.
Build and test parity
Run the same type-check, lint, test, and build commands in local development and continuous integration. If aliases, environment variables, or custom build tools are involved, test a production-like build rather than relying only on the development server. When comparing build tools, keep the roles distinct: a transpiler may emit code quickly while tsc --noEmit performs type checking. The TypeScript build tools comparison can help clarify those responsibilities.
Common mistakes
- Using assertions everywhere:
as Usersilences the compiler but does not make the value a user. Replace assertions at external boundaries with validation or narrowing. - Putting all types in one global file: A large declarations file becomes difficult to own and encourages unrelated features to depend on each other. Keep types near their domain until sharing is necessary.
- Confusing generated types with validation: An API client type or database-generated type describes an expected shape; it does not necessarily protect the application from malformed runtime input.
- Making every component a client component: This can blur data ownership and increase the amount of code that must run in the browser. Add client execution where interactivity requires it.
- Hiding errors behind broad catch blocks: Returning an empty array for every failure makes outages look like valid empty states. Model loading, empty, and error states separately.
- Changing framework and TypeScript settings together without checkpoints: Upgrade one layer at a time where practical, record the commands used, and keep a small passing commit before changing the next layer.
When to revisit
Use this checklist before a seasonal planning cycle, a major feature, or a framework and dependency upgrade. It is also worth revisiting when the team changes its routing model, introduces a new API, moves data access into a shared package, or begins using a monorepo. In a monorepo, review project references, package boundaries, and which package owns each shared type; the TypeScript monorepo guide covers those concerns.
Before acting, run this short review:
- Run the full type check with strict settings and resolve new errors instead of adding unexplained exceptions.
- Inspect server-client imports for newly introduced leaks of secrets, database code, or non-serializable values.
- List every external data boundary changed by the work and confirm that each has runtime validation and an intentional error path.
- Verify aliases, environment variables, generated declarations, test configuration, and production builds together.
- Update shared contracts and fixtures when the API or domain model changes, then test both valid and invalid inputs.
- Record framework-specific assumptions in the project documentation so the next upgrade starts with context.
Next.js and TypeScript projects remain easier to maintain when configuration, data flow, and ownership are reviewed as one system. Repeating this checklist whenever workflows or tools change helps preserve that clarity without requiring a large rewrite.