diff --git a/.changeset/flat-goats-pull.md b/.changeset/flat-goats-pull.md new file mode 100644 index 0000000..db2db7e --- /dev/null +++ b/.changeset/flat-goats-pull.md @@ -0,0 +1,165 @@ +--- +"next-safe-navigation": minor +--- +**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](https://nextjs.org) with Zod schemas.** + +> [!WARNING] +> Ensure `experimental.typedRoutes` is not enabled in `next.config.js` + +### Declare your application routes and parameters in a single place +```ts +// 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(), + }), + }), + }), +); +``` + +### Runtime validation for React Server Components (RSC) +> [!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 with `URLSearchParams` 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 infer `params` or `searchParams` from your declared routes to be used in page props: +> ```ts +> interface CustomersPageProps { +> // ❌ Do not declare your params | searchParam types +> searchParams?: ReturnType +> } +>``` +> 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. + +```ts +// 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 ( +
+ + + +
+ ) +}; + +/* --------------------------------- */ + +// 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 ( +
+ +
+ ) +}; +``` + +### Runtime validation for Client Components +```ts +// 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 ( +
+ + + +
+ ) +}; + +/* --------------------------------- */ + +// 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 ( +
+ +
+ ) +}; +``` + +Use throughout your codebase as the single source for navigating between routes: + +```ts +import { routes } from "@/shared/navigation"; + +export function Header() { + return ( + + ) +}; + +export function CustomerInvoices({ invoices }) { + return ( + + ) +}; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea1c67d..647c51c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -162,7 +162,7 @@ jobs: if: steps.bun-cache.outputs.cache-hit != 'true' run: bun install --frozen-lockfile - - name: ♼ Change package.json name + - name: 🏷️ Overwrite package name with user scope uses: sergeysova/jq-action@v2 with: cmd: echo "$( jq '.name="@${{ github.repository }}"' package.json )" > package.json