Static type and runtime validation for navigating routes in NextJS App Router with Zod schemas
MIT License
Bot releases are visible (Hide)
a953425
Thanks @lukemorales! - Use iterator to check URLSearchParams
size in older browsers by @wontondonPublished by github-actions[bot] 7 months ago
4f0e3a5
Thanks @lukemorales! - Fix type definition for useSafeParams
when route has both params
and searchParams
definedPublished by github-actions[bot] 7 months ago
#15 31d794e
Thanks @lukemorales! - Add support for experimental.typedRoutes
You may now enable experimental.typedRoutes
in next.config.js
to have a better and safer experience with autocomplete when defining your routes
Published by github-actions[bot] 7 months ago
fc55e1d
Thanks @lukemorales! - Add better support for Catch-all Segmentsa5194b3
Thanks @lukemorales! - Use regex instead of for loop to replace path paramsPublished by github-actions[bot] 9 months ago
be00799
Thanks @lukemorales! - Fix route builder closure mutating same path stringPublished by github-actions[bot] 9 months ago
8cbcb51
Thanks @lukemorales! - Initial release
Static type and runtime validation of routes, route params and query string parameters on client and server components for navigating routes in NextJS App Router with Zod schemas.
[!WARNING]
Ensureexperimental.typedRoutes
is not enabled innext.config.js
// src/shared/navigation.ts
import { createNavigationConfig } from "next-safe-navigation";
import { z } from "zod";
export const { routes, useSafeParams, useSafeSearchParams } =
createNavigationConfig((defineRoute) => ({
home: defineRoute("/"),
customers: defineRoute("/customers", {
search: z
.object({
query: z.string().default(""),
page: z.coerce.number().default(1),
})
.default({ query: "", page: 1 }),
}),
invoice: defineRoute("/invoices/[invoiceId]", {
params: z.object({
invoiceId: z.string(),
}),
}),
}));
[!IMPORTANT]
The output of a Zod schema might not be the same as its input, since schemas can transform the values during parsing (e.g.:z.coerce.number()
), especially when dealing withURLSearchParams
where all values are strings and you might want to convert params to different types. For this reason, this package does not expose types to inferparams
orsearchParams
from your declared routes to be used in page props:interface CustomersPageProps { // ❌ Do not declare your params | searchParam types searchParams?: ReturnType<typeof routes.customers.$parseSearchParams>; }
Instead, it is strongly advised that you parse the params in your server components to have runtime validated and accurate type information for the values in your app.
// src/app/customers/page.tsx
import { routes } from "@/shared/navigation";
interface CustomersPageProps {
// ✅ Never assume the types of your params before validation
searchParams?: unknown
}
export default async function CustomersPage({ searchParams }: CustomersPageProps) {
const { query, page } = routes.customers.$parseSearchParams(searchParams);
const customers = await fetchCustomers({ query, page });
return (
<main>
<input name="query" type="search" defaultValue={query} />
<Customers data={customers} />
</main>
)
};
/* --------------------------------- */
// src/app/invoices/[invoiceId]/page.tsx
import { routes } from "@/shared/navigation";
interface InvoicePageProps {
// ✅ Never assume the types of your params before validation
params?: unknown
}
export default async function InvoicePage({ params }: InvoicePageProps) {
const { invoiceId } = routes.invoice.$parseParams(params);
const invoice = await fetchInvoice(invoiceId);
return (
<main>
<Invoice data={customers} />
</main>
)
};
// src/app/customers/page.tsx
'use client';
import { useSafeSearchParams } from "@/shared/navigation";
export default function CustomersPage() {
const { query, page } = useSafeSearchParams('customers');
const customers = useSuspenseQuery({
queryKey: ['customers', { query, page }],
queryFn: () => fetchCustomers({ query, page}),
});
return (
<main>
<input name="query" type="search" defaultValue={query} />
<Customers data={customers.data} />
</main>
)
};
/* --------------------------------- */
// src/app/invoices/[invoiceId]/page.tsx
'use client';
import { useSafeParams } from "@/shared/navigation";
export default function InvoicePage() {
const { invoiceId } = useSafeParams('invoice');
const invoice = useSuspenseQuery({
queryKey: ['invoices', { invoiceId }],
queryFn: () => fetchInvoice(invoiceId),
});
return (
<main>
<Invoice data={invoice.data} />
</main>
)
};
Use throughout your codebase as the single source for navigating between routes:
import { routes } from "@/shared/navigation";
export function Header() {
return (
<nav>
<Link href={routes.home()}>Home</Link>
<Link href={routes.customers()}>Customers</Link>
</nav>
)
};
export function CustomerInvoices({ invoices }) {
return (
<ul>
{invoices.map(invoice => (
<li key={invoice.id}>
<Link href={routes.invoice({ invoiceId: invoice.id })}>
View invoice
</Link>
</li>
))}
</ul>
)
};