next-safe-navigation
v0.4.0
Published
Type-safe navigation for NextJS App router with Standard Schema support (Zod, Valibot, ArkType, etc.)
Maintainers
Readme
📦 Install
Safe NextJS Navigation is available as a package on NPM, install with your favorite package manager:
npm install next-safe-navigationYou'll also need to install a Standard Schema compatible validation library:
# Choose one:
npm install zod # Zod (most popular)
npm install valibot # Valibot (lightweight)
npm install arktype # ArkType (fast)⚡ Quick start
[!TIP] Enable
experimental.typedRoutesinnext.config.jsfor a better and safer experience with autocomplete when defining your routes
Declare your application routes and parameters in a single place
// 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(),
}),
}),
shop: defineRoute('/support/[...tickets]', {
params: z.object({
tickets: z.array(z.string()),
}),
}),
shop: defineRoute('/shop/[[...slug]]', {
params: z.object({
// ⚠️ Remember to always set your optional catch-all segments
// as optional values, or add a default value to them
slug: z.array(z.string()).optional(),
}),
}),
}));Runtime validation for React Server Components (RSC)
[!IMPORTANT] The output of a schema might not be the same as its input, since schemas can transform the values during parsing (e.g.: string to number coercion), especially when dealing with
URLSearchParamswhere all values are strings and you might want to convert params to different types. For this reason, this package does not expose types to inferparamsorsearchParamsfrom 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>
)
};Runtime validation for Client Components
// 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>
)
};🔄 Standard Schema Support
This library now supports Standard Schema, which means you can use any compatible validation library:
Using Zod
// src/shared/navigation.ts
import { createNavigationConfig } from 'next-safe-navigation';
import { z } from 'zod';
export const { routes, useSafeParams, useSafeSearchParams } =
createNavigationConfig((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(),
}),
}),
}));Using Valibot
// src/shared/navigation.ts
import { createNavigationConfig } from 'next-safe-navigation';
import * as v from 'valibot';
export const { routes, useSafeParams, useSafeSearchParams } =
createNavigationConfig((defineRoute) => ({
customers: defineRoute('/customers', {
search: v.objectWithRest(
{
query: v.optional(v.string(), ''),
page: v.optional(v.pipe(v.string(), v.transform(Number)), 1),
},
v.never(),
),
}),
invoice: defineRoute('/invoices/[invoiceId]', {
params: v.object({
invoiceId: v.string(),
}),
}),
}));Using ArkType
// src/shared/navigation.ts
import { createNavigationConfig } from 'next-safe-navigation';
import { type } from 'arktype';
export const { routes, useSafeParams, useSafeSearchParams } =
createNavigationConfig((defineRoute) => ({
customers: defineRoute('/customers', {
search: type({
'query?': "string = ''",
'page?': 'string.numeric.parse = 1',
}),
}),
invoice: defineRoute('/invoices/[invoiceId]', {
params: type({
invoiceId: 'string',
}),
}),
}));