Next.js and TypeScript work well together, but the App Router introduces new places where types can quietly drift: route params, server actions, data loading, form handling, and the boundary between server and client components. This guide gives you a durable structure for keeping a modern Next.js app type-safe without overengineering it. Rather than chasing framework-specific tricks, it focuses on patterns that survive version changes: validate input at boundaries, infer types from implementation where possible, keep server-only logic server-only, and make route shapes explicit.
Overview
If you want a practical nextjs typescript guide, the main goal is simple: reduce the number of places where your app can compile but still break at runtime. In an App Router project, those weak points usually appear in five areas:
- Route params and search params arrive as external input.
- Server actions often handle untrusted form data.
- Data fetching can drift away from the real API response.
- Client components may receive loosely typed props from server components.
- Shared utilities can become too generic and stop protecting real use cases.
The durable answer is not “add more types everywhere.” It is to be strict at the edges and simple in the middle.
A good App Router TypeScript setup usually follows these rules:
- Treat all external data as unknown until validated.
- Prefer inferred return types for internal helpers, but explicit public types for shared contracts.
- Model route shapes close to the route files.
- Use narrow prop types for client components.
- Keep server actions small and typed around real inputs and outputs.
This keeps the codebase readable while still giving you the benefits people usually want from nextjs app router typescript: safer refactors, clearer component boundaries, and fewer “why did this become any?” moments.
If your project still has TypeScript configuration issues, it is worth reviewing a focused module resolution troubleshooting guide and a dedicated path aliases guide before tightening application-level patterns. Many typing problems in Next.js projects begin in config rather than in React code.
Template structure
Use the following structure as a baseline for a type-safe Next.js App Router project. The exact folder names can vary, but the boundaries matter more than the layout.
app/
blog/
[slug]/
page.tsx
users/
[userId]/
page.tsx
actions.ts
components/
user-card.tsx
lib/
validation/
user.ts
blog.ts
api/
users.ts
types/
routes.ts
actions.ts
The design behind this structure is straightforward:
- app/ owns route-level concerns.
- components/ contains rendering concerns, especially reusable UI pieces.
- lib/validation/ defines runtime schemas and helpers for untrusted data.
- lib/api/ wraps fetch or database access with typed functions.
- types/ holds stable shared contracts, not every type in the app.
This separation helps prevent a common failure mode in nextjs typescript patterns: putting every type into a global folder while validation, route logic, and rendering drift apart.
1. Type route params explicitly
Route params are one of the easiest places to lose type safety because they feel predictable. In reality, they are still user-controlled input coming from the URL.
type BlogPageProps = {
params: {
slug: string;
};
};
export default async function BlogPage({ params }: BlogPageProps) {
const { slug } = params;
return <div>Post: {slug}</div>;
}
For catch-all or optional segments, make the shape explicit instead of relying on memory.
type DocsPageProps = {
params: {
slug: string[];
};
};
If multiple routes use the same param names, create small shared aliases in types/routes.ts:
export type SlugParam = { slug: string };
export type UserIdParam = { userId: string };
This is often enough for nextjs typed params without introducing elaborate route-type systems.
2. Parse search params at the boundary
Search params are even less trustworthy than route params because they are often optional, repeated, or malformed. Treat them as input that needs normalization.
type UsersPageProps = {
searchParams?: {
page?: string;
sort?: string;
};
};
function parsePage(value: string | undefined): number {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : 1;
}
export default function UsersPage({ searchParams }: UsersPageProps) {
const page = parsePage(searchParams?.page);
const sort = searchParams?.sort === 'name' ? 'name' : 'createdAt';
return <div>Page {page}, sort {sort}</div>;
}
The useful pattern is: accept broad input, convert once, and work with a narrower internal type afterward.
3. Keep data functions typed by implementation, not wishful thinking
One of the most common mistakes in TypeScript projects is declaring API return types that do not match reality. In Next.js, that usually shows up in route loaders, server components, and actions.
type User = {
id: string;
name: string;
email: string;
};
async function getUser(userId: string): Promise<User | null> {
const res = await fetch(`https://example.com/api/users/${userId}`);
if (!res.ok) return null;
const data: unknown = await res.json();
return parseUser(data);
}
The important detail is the unknown. It forces a validation step instead of pretending the remote API already matches your local type. If you want a deeper approach to that pattern, see how to type API responses without lying to the compiler.
4. Use runtime validation for external input
TypeScript cannot validate network responses, form submissions, headers, cookies, or URL values at runtime. That is why App Router projects benefit from a validation library or carefully written parsing helpers.
type User = {
id: string;
name: string;
email: string;
};
function parseUser(value: unknown): User {
if (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value &&
typeof value.id === 'string' &&
typeof value.name === 'string' &&
typeof value.email === 'string'
) {
return value as User;
}
throw new Error('Invalid user payload');
}
In larger applications, schema-driven validation is usually easier to maintain. If you are comparing libraries, this overview of runtime validation options for TypeScript is a useful companion.
5. Keep client component props small and serializable
Server components can do the heavy lifting. Client components should usually receive narrowed, plain-data props rather than large rich objects or functions with unclear contracts.
'use client';
type UserCardProps = {
id: string;
name: string;
isActive: boolean;
};
export function UserCard({ id, name, isActive }: UserCardProps) {
return (
<div data-user-id={id}>
<h2>{name}</h2>
<p>{isActive ? 'Active' : 'Inactive'}</p>
</div>
);
}
This pattern makes hydration boundaries easier to reason about and reduces accidental coupling between client UI and server data sources.
6. Type server actions around input and result
A strong server actions typescript pattern is to define a small result union and parse the incoming form data immediately.
'use server';
type CreatePostResult =
| { ok: true; postId: string }
| { ok: false; fieldErrors: { title?: string } };
export async function createPost(formData: FormData): Promise<CreatePostResult> {
const titleValue = formData.get('title');
if (typeof titleValue !== 'string' || titleValue.trim().length === 0) {
return {
ok: false,
fieldErrors: { title: 'Title is required' },
};
}
const postId = crypto.randomUUID();
return {
ok: true,
postId,
};
}
This is more durable than returning loosely shaped objects and trying to remember the possible outcomes later in the UI.
How to customize
The template above is intentionally plain. The value comes from adapting it to your project without losing the core safety guarantees.
For content-heavy apps
If your Next.js app has blogs, docs, marketing pages, or CMS-driven routes, focus on typed route params and validated content models.
- Create per-route param types such as
SlugParam. - Parse CMS responses instead of trusting generated client types blindly.
- Keep rendering components independent from CMS transport objects.
A practical rule is to convert external content into your own domain type before passing it deeper into the app.
For dashboard or SaaS apps
These projects usually have more forms, filters, and authenticated server actions.
- Normalize
searchParamsinto typed filter objects. - Return discriminated unions from actions.
- Keep database or API access inside server-only helpers.
- Pass only the fields the client view actually needs.
This avoids the gradual sprawl where every component starts depending on a giant “user record” type.
For API-heavy full-stack apps
If the Next.js layer is mostly a frontend over external services, make boundary validation your top priority.
- Use
unknownfor raw responses. - Convert payloads in one place.
- Export validated return types from your API wrapper functions.
That pattern is especially helpful when backend teams change payload shapes without warning.
For monorepos
In monorepos, shared types can be useful, but only when they reflect stable contracts. Do not move every local component prop into a shared package.
- Share domain contracts and validation schemas when they are truly reused.
- Keep route-specific and UI-specific types local to the app.
- Review project references and package boundaries before debugging app-level type errors.
If that sounds familiar, this TypeScript monorepo setup guide can help you separate infrastructure issues from framework issues.
For teams standardizing conventions
If multiple developers contribute to the same Next.js codebase, document a few simple choices:
- When to use a shared
typeversus a local inferred type. - How route params should be declared.
- How API responses are validated.
- What a standard server action result looks like.
- How linting enforces consistency.
For that last point, a clean ESLint + TypeScript flat config setup makes the codebase easier to keep consistent over time.
Teams often also benefit from settling the local convention around interface vs type so discussions about modeling stay focused on the data rather than the syntax.
Examples
Here are a few reusable examples that show how these patterns fit together in real App Router code.
Example 1: Typed dynamic route with validated fetch
type UserPageProps = {
params: {
userId: string;
};
};
type User = {
id: string;
name: string;
};
function parseUser(value: unknown): User {
if (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
typeof value.id === 'string' &&
typeof value.name === 'string'
) {
return value as User;
}
throw new Error('Invalid user');
}
async function getUser(userId: string): Promise<User | null> {
const res = await fetch(`https://example.com/users/${userId}`);
if (!res.ok) return null;
return parseUser(await res.json());
}
export default async function UserPage({ params }: UserPageProps) {
const user = await getUser(params.userId);
if (!user) {
return <div>User not found</div>;
}
return <h1>{user.name}</h1>;
}
The value here is not complexity. It is that the route contract, fetch contract, and rendering contract are all clear and separate.
Example 2: Search params normalized once
type SearchProps = {
searchParams?: {
q?: string;
page?: string;
};
};
type UserFilters = {
query: string;
page: number;
};
function getUserFilters(searchParams: SearchProps['searchParams']): UserFilters {
return {
query: typeof searchParams?.q === 'string' ? searchParams.q : '',
page: Number(searchParams?.page) > 0 ? Number(searchParams?.page) : 1,
};
}
export default function UsersPage({ searchParams }: SearchProps) {
const filters = getUserFilters(searchParams);
return <div>Searching for {filters.query} on page {filters.page}</div>;
}
This small normalization helper prevents repeated parsing logic throughout the page tree.
Example 3: Server action with a discriminated union result
'use server';
type SaveProfileResult =
| { ok: true }
| { ok: false; errors: { email?: string } };
export async function saveProfile(formData: FormData): Promise<SaveProfileResult> {
const email = formData.get('email');
if (typeof email !== 'string' || !email.includes('@')) {
return {
ok: false,
errors: { email: 'Enter a valid email address' },
};
}
return { ok: true };
}
On the client side, checking result.ok gives you safe narrowing immediately. This is one of the most practical TypeScript patterns in full-stack React work.
Example 4: Narrow client props from a server component
type Product = {
id: string;
name: string;
priceInCents: number;
internalNotes: string;
};
function ProductView(props: { id: string; name: string; priceLabel: string }) {
return <div>{props.name}: {props.priceLabel}</div>;
}
export default async function ProductPage() {
const product: Product = {
id: 'p1',
name: 'Notebook',
priceInCents: 1200,
internalNotes: 'warehouse-only',
};
return (
<ProductView
id={product.id}
name={product.name}
priceLabel={`$${(product.priceInCents / 100).toFixed(2)}`}
/>
);
}
This prevents internal-only fields from leaking into UI dependencies.
When to update
Return to this setup when the framework changes, but also when your application shape changes. The most useful update moments are practical, not theoretical.
Revisit your App Router typing patterns when:
- You add new route groups or dynamic segments. Check that param types still match the file structure.
- You introduce server actions for important user flows. Standardize action result unions before many variations appear.
- You switch data sources or API providers. Revalidate assumptions around response parsing.
- You move logic into shared packages or a monorepo. Confirm which types should remain local.
- Your linting or tsconfig changes. Small config changes can alter how import paths, module resolution, or type checking behave.
- You notice repeated parsing code. That is usually a sign a boundary helper should be extracted.
A practical maintenance checklist looks like this:
- Audit dynamic routes and document their param shapes.
- Find every place where external data enters the app.
- Replace unvalidated assumptions with parsing or schema validation.
- Review client component props and remove fields they do not need.
- Normalize server action return shapes.
- Run linting and TypeScript in CI so drift gets caught early.
If errors start appearing after a config or dependency update, check the basics before rewriting application code. Missing globals, incorrect libs, or broken path mapping often masquerade as framework problems. These guides on fixing missing type errors and on a stable Node.js + TypeScript setup can help isolate those issues.
The long-term pattern is simple enough to remember: define route inputs clearly, validate anything untrusted, infer what you can from real implementation, and keep server/client boundaries narrow. That approach stays useful even as Next.js evolves, which is exactly what makes it worth revisiting.