next-safe-navigation

repository·main·Indexed 41 days ago

https://github.com/lukemorales/next-safe-navigation

Type-safe navigation for Next.js App Router with Standard Schema support (Zod, Valibot, ArkType, etc.). Version 0.4.0 provides static type safety and runtime validation for route parameters and search parameters using a single source of truth via createNavigationConfig.

Tokens
4.8K
Snippets
14
Records
14
Agent score
50%

What's inside next-safe-navigation

  1. Install next-safe-navigation

    main

    Install the core package using your preferred package manager, then install one Standard Schema compatible validation library (Zod, Valibot, or ArkType) to handle route parameter validation.

    # Install core package
    npm install next-safe-navigation
    
    # Install a validation library (choose one)
    npm install zod           # Zod (most popular)
    npm install valibot       # Valibot (lightweight)
    npm install arktype       # ArkType (fast)
  2. Validate route parameters in React Server Components (RSC)

    main

    In Server Components, do not attempt to manually type params or searchParams in your page props. Because schemas can transform values (e.g., coercing a string to a number), you should treat them as unknown and use the generated $parseParams or $parseSearchParams methods to get validated, typed data.

    Methods:

    • routes.<routeName>.$parseParams(params): Validates dynamic route segments.
    • routes.<routeName>.$parseSearchParams(searchParams): Validates query string parameters.
    // 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={invoice} />
        </main>
      )
    };
  3. Validate route parameters in Client Components

    main

    Use the provided hooks to access validated route parameters and search parameters in Client Components.

    Hooks:

    • useSafeParams(<routeName>): Returns the validated params for the specified route.
    • useSafeSearchParams(<routeName>): Returns the validated searchParams for the specified route.
    // 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 customers = useSuspenseQuery({
        queryKey: ['invoices', { invoiceId }],
        queryFn: () => fetchInvoice(invoiceId),
      });
    
      return (
        <main>
          <Invoice data={customers.data} />
        </main>
      );
    }
  4. Declare application routes and parameters

    main

    Use createNavigationConfig to define your application's route structure in a single location. This function accepts a callback that provides a defineRoute helper. You can define params for dynamic segments and search for query string parameters using a validation schema.

    Tip: Enable experimental.typedRoutes in next.config.js for improved autocomplete experience.

    // 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(),
          }),
        }),
      }));
  5. Navigate between routes using the routes object

    main

    The routes object returned by createNavigationConfig provides functions for each defined route. To navigate, call the route function with the required parameters. This ensures type safety when generating URLs for <Link> components or programmatic navigation.

    import { routes } from "@/shared/navigation";
    import Link from 'next/link';
    
    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>
      );
    }
  6. Configure routes with Valibot

    main

    When using Valibot, define your params and search schemas using Valibot's syntax within the createNavigationConfig callback.

    // 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(),
          }),
        }),
      }));
  7. Configure routes with ArkType

    main

    When using ArkType, define your params and search schemas using ArkType's type syntax within the createNavigationConfig callback.

    // 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',
          }),
        }),
      }));
  8. Create a type-safe route builder with `makeRouteBuilder`

    main

    Use makeRouteBuilder to define a route with its associated path parameters and search parameters using Standard Schema (e.g., Zod, Valibot, ArkType). This creates a function that, when called with validated inputs, returns a correctly formatted URL string.

    Key Behaviors:

    • Path Parameters: If your path contains dynamic segments like [id] or catch-all segments like [[...slug]], you must provide a params schema in the schemas object. If you omit it, the function will throw a runtime error: Validation missing for path params: "/your-path".
    • Search Parameters: You can optionally provide a search schema to validate and include URL search parameters.
    • Next.js Integration: The builder respects Next.js Route types. If experimental.typeRoutes is enabled in your Next.js config, the builder will provide autocomplete for your available routes.
    • Return Value: The resulting function returns a string representing the full URL (including the query string if search parameters are provided).
    import { makeRouteBuilder } from 'next-safe-navigation';
    import { z } from 'zod';
    
    // 1. Define the route builder
    const productRoute = makeRouteBuilder(
      '/products/[id]',
      {
        params: z.object({ id: z.string() }),
        search: z.object({ color: z.string().optional() }),
      }
    );
    
    // 2. Use the builder to generate a URL
    const url = productRoute({ 
      id: '123', 
      search: { color: 'red' } 
    });
    // Returns: "/products/123?color=red"
  9. Validate data using Standard Schema compliant validators

    main

    The library provides two helper functions to validate data using any validator that implements the Standard Schema specification (such as Zod, Valibot, or ArkType). These helpers handle the extraction of error messages from the schema's issues array and throw a descriptive Error if validation fails.

    Use validateStandardSchema for asynchronous validation (when the schema's validate method returns a Promise) and validateStandardSchemaSync for synchronous validation.

    import { validateStandardSchema, validateStandardSchemaSync } from 'next-safe-navigation';
    
    // Asynchronous validation
    const result = await validateStandardSchema(mySchema, input);
    
    // Synchronous validation
    const resultSync = validateStandardSchemaSync(mySchema, input);
  10. Create a navigation configuration with createNavigationConfig

    main

    The primary entrypoint for next-safe-navigation is createNavigationConfig. Use this function to declare your application's routes and their associated parameters in a single, type-safe location. This configuration serves as the source of truth for both runtime validation and type-safe navigation throughout your Next.js application.

    import { createNavigationConfig } from 'next-safe-navigation';
    
    // Define your routes and parameters here
    export const config = createNavigationConfig({
      // route definitions go here
    });
  11. Configure schemas in `makeRouteBuilder`

    main

    The schemas argument in makeRouteBuilder determines how path and search parameters are validated. It follows these patterns based on the path provided:

    Path Typeschemas Object Shape
    Static Path (e.g., /about){ search?: SearchSchema }
    Dynamic Path (e.g., /user/[id]){ params: ParamsSchema, search?: SearchSchema }

    Note: If the path contains dynamic segments (detected via [param] or [[...param]] syntax), the params key is required. Failure to provide it will result in a runtime error.

    // Example: Dynamic path requiring params
    const dynamicRoute = makeRouteBuilder(
      '/blog/[slug]',
      {
        params: myParamSchema,
        search: mySearchSchema
      }
    );
  12. Use validateStandardSchema for asynchronous validation

    main

    Use validateStandardSchema when working with schemas that may perform asynchronous validation. It automatically awaits the result if the schema's ~standard.validate method returns a Promise. If validation fails, it throws an Error containing a formatted string of all issues, including their paths (e.g., path.to.key: error message).

    export async function validateStandardSchema<T extends StandardSchemaV1>(
      schema: T,
      input: unknown,
    ): Promise<StandardSchemaV1.InferOutput<T>>