Declarative Routing
Type-safe route definitions
Overview
Declarative routing provides type-safe navigation by generating route functions from page.info.ts files. Each route has full TypeScript support for parameters and search params.
page.info.ts Files
Every page can have a page.info.ts file that defines its route configuration.
Basic Route
// src/app/[locale]/(site)/about/page.info.ts
import { z } from "zod";
export const Route = {
name: "PageAbout" as const,
params: z.object({}),
};The name property must use as const to ensure proper type inference.
Route with Parameters
// src/app/[locale]/(site)/users/[id]/page.info.ts
import { z } from "zod";
export const Route = {
name: "PageUserId" as const,
params: z.object({
id: z.string().uuid(),
}),
};Route with Search Params
// src/app/[locale]/(site)/search/page.info.ts
import { localeSchema } from "@/i18n/routing";
import { siteSearchParams } from "@/lib/utils/schema-utils";
import { z } from "zod";
export const Route = {
name: "PageSearch" as const,
params: localeSchema.extend({}),
search: siteSearchParams.extend({
q: z.string().optional(),
page: z.coerce.number().optional(),
}),
};Important Rules:
- Always extend
localeSchemafor params - Always extend
siteSearchParamsfor search params - Search params must be optional (
.optional()) - Search params must be
z.string()- usez.coercefor type transformation
Route Definition
Required Properties
| Property | Type | Description |
|---|---|---|
name | string | Route identifier (as const) |
params | ZodObject | Zod schema for params |
Optional Properties
| Property | Type | Description |
|---|---|---|
search | ZodObject | Zod schema for search params |
Generated Route Functions
Each page.info.ts generates route functions with TypeScript support:
Route Function
import { PageUserId } from "@/routes";
// Type-safe URL generation
const url = PageUserId({ id: "123" });
// Returns: "/en/users/123" (or current locale)Link Component
// Basic link
<PageUserId.Link id="123">View User</PageUserId.Link>
// With additional props
<PageUserId.Link id="123" className="text-blue-500">
View User
</PageUserId.Link>ParamsLink Component
For passing all params in a single object:
<PageUserId.ParamsLink params={{ id: "123" }}>
View User
</PageUserId.ParamsLink>Route Name
const routeName = PageUserId.routeName; // "PageUserId"Route Parameters
Path Parameters
Defined in Next.js folder structure:
src/app/[locale]/(site)/
├── users/
│ └── [id]/
│ ├── page.tsx
│ └── page.info.ts // Defines id paramexport const Route = {
name: "PageUserId" as const,
params: z.object({
id: z.string(),
}),
};Usage:
<PageUserId.Link id={user.id}>View</PageUserId.Link>Multiple Parameters
// Route: /posts/[category]/[slug]/page.info.ts
export const Route = {
name: "PagePostCategorySlug" as const,
params: z.object({
category: z.string(),
slug: z.string(),
}),
};Usage:
<PagePostCategorySlug.Link category="tech" slug="my-post">
Read Post
</PagePostCategorySlug.Link>Locale Parameter
Always extend localeSchema:
import { localeSchema } from "@/i18n/routing";
export const Route = {
name: "PageMyPage" as const,
params: localeSchema.extend({
id: z.string(),
}),
};Search Parameters
Defining Search Params
import { siteSearchParams } from "@/lib/utils/schema-utils";
export const Route = {
name: "PageSearch" as const,
params: localeSchema.extend({}),
search: siteSearchParams.extend({
q: z.string().optional(),
page: z.coerce.number().optional(),
sort: z.enum(["asc", "desc"]).optional(),
}),
};Usage
// Link with search params
<PageSearch.Link search={{ q: "hello", page: 1 }}>
Search
</PageSearch.Link>
// URL with search params
const url = PageSearch({}, { q: "hello", page: 1 });
// Returns: "/en/search?q=hello&page=1"Type Coercion
Search params from URL are always strings. Use z.coerce for type conversion:
search: siteSearchParams.extend({
page: z.coerce.number().optional(), // "1" → 1
active: z.coerce.boolean().optional(), // "true" → true
})Accessing Route Params
In Page Components
// src/app/[locale]/(site)/users/[id]/page.tsx
type Props = { params: Promise<{ locale: string; id: string }> };
export default async function Page({ params }: Props) {
const { id } = await params;
return <div>User ID: {id}</div>;
}In Client Components
"use client";
import { useParams } from "next/navigation";
export function UserProfile() {
const params = useParams<{ id: string }>();
return <div>User ID: {params.id}</div>;
}Extract Params from URL
import { extractParamsFromRoute } from "@/routes/makeRoute";
import { usePathname } from "next/navigation";
const pathname = usePathname();
const params = extractParamsFromRoute(pathname, PageUserId);
// params.id is available if route matchesRoute Groups
Routes inside parentheses () are not included in the URL:
src/app/[locale]/
├── (site)/ # Not in URL
│ ├── about/
│ └── pricing/
├── (auth)/ # Not in URL
│ ├── login/
│ └── register/
└── (dashboard)/ # Not in URL
└── dashboard/Naming Conventions
Route Name Pattern
Route names follow this pattern:
Page + [Folder1] + [Folder2] + ... + [FolderN]Examples:
| Route Path | Name |
|---|---|
/about | PageAbout |
/dashboard | PageDashboard |
/dashboard/api-keys | PageDashboardApiKeys |
/dashboard/organizations/[slug] | PageDashboardOrganizationSlug |
/settings/profile | PageSettingsProfile |
Route groups like (site) and (dashboard) are excluded from the name.
Best Practices
1. Always Use Type-Safe Routes
// ✅ Good - Type-safe
<PageUserId.Link id={user.id}>View</PageUserId.Link>
// ❌ Bad - String literal
<Link href={`/users/${user.id}`}>View</Link>2. Extend Required Schemas
// ✅ Good
params: localeSchema.extend({ id: z.string() }),
search: siteSearchParams.extend({ q: z.string().optional() }),
// ❌ Bad - Missing extensions
params: z.object({ id: z.string() }),
search: z.object({ q: z.string().optional() }),3. Make Search Params Optional
// ✅ Good
search: siteSearchParams.extend({
q: z.string().optional(),
}),
// ❌ Bad - Non-optional causes validation errors
search: siteSearchParams.extend({
q: z.string(),
}),4. Use Descriptive Names
// ✅ Good
name: "PageDashboardApiKeys" as const,
// ❌ Bad - Unclear
name: "PageDak" as const,