paramour
Migrate

Concept Map

Every next-typesafe-url concept and its paramour equivalent.

Same architecture, different vocabulary. Both libraries put a definition file next to each route, run a codegen step, build links through a typed helper, and read params through hooks. This table translates the whole surface; the sections after it expand the three mappings that aren't one-to-one renames.

Code on the left is next-typesafe-url v6.1.0, shown as plain (uncompiled) snippets. Code on the right is live — compiled against the paramour packages on every docs build.

The map

next-typesafe-urlparamourNotes
routeType.ts beside the pageroute.def.ts beside the pageSame convention, different filename (route objects)
const Route = {...} satisfies DynamicRoutedefineAppRoute("/path/[seg]", { params, search })The path is an argument, checked against the config at compile time
routeParams: z.object({...})params: { seg: p.integer() }One codec per [segment] (p.* builders)
searchParams: z.object({...})search: { q: p.string() }Or rawSearch(schema) to keep one whole-object schema
Object-valued search key (JSON on the wire)p.json(schema)The only codec that reproduces the old URL encoding — see the guide
.optional() / .default() on zod schemas.optional() / .default() / .catch() on codecsParamour defaults also elide from URLs (serialization)
z.string().transform((s) => new Date(s))p.isoDate() / p.timestamp()Dates are first-class; the z.date() prohibition is gone
$path({ route, routeParams, searchParams })href(route, { params, search })A route object instead of a string key; returns a branded string (href)
withParamValidation(Page, Route)await route.parse(props) / safeParseNo direct equivalent by design — parsing is an explicit call, not a wrapper
InferPagePropsType<RouteType>RoutePropsProps stay one generic shape; the parse call produces the route-specific types
InferRoute<RouteType>InferRouteParams / InferSearchOutputParams inference hangs off the route, search off the config
useRouteParams(Route.routeParams) (/app)useRouteParams(route) from @paramour-js/next/appTakes the whole route; returns a two-arm SafeResult — no isLoading
useSearchParams(Route.searchParams) (/app)useSearch(route) from @paramour-js/next/appSame; OrThrow variants throw to your error boundary (App hooks)
useRouteParams / useSearchParams (/pages)Same names, from @paramour-js/next/pagesThree-arm result; pending replaces isLoading (Pages hooks)
parseServerSideParams({ params, schema })route.parseContext(ctx) / safeParseContextPages server parsing moves onto the route object
next-typesafe-url CLI (mandatory, --watch)paramour generate / withTypedRoutes (optional)The library works before the first generate; codegen adds path checking (registry)
Generated _next-typesafe-url_.d.tsGenerated paramour-env.d.tsBoth type-only; they augment different modules, so they coexist during migration
Hand-rolled generateStaticParams valuesencodeStaticParams(route, values)No helper existed before; this one is net-new
Raw ZodError on failureParamourError hierarchy with issues[]Aggregated per-key issues, one shape everywhere (errors)
Catch-all via z.array(z.string())An element codec on [...slug] / [[...slug]]The codec describes one element; arrayness comes from the segment kind

Three rows deserve honesty up front. withParamValidation has no replacement wrapper — migrating means adding a parse call inside the page, which the guide walks through. App Router hooks have no isLoading arm — if your components branch on it, that branch becomes dead code (see differences). And the JSON-blob URL format survives only where you opt into p.json per key (see differences).

Schemas become codecs

The one mapping that isn't a rename. A zod schema validates; a codec validates and owns the wire format — how the value is written into the URL and read back out. Your zod schemas don't disappear: they slot into codecs as refinements, because paramour accepts any Standard Schema validator.

Before, with next-typesafe-url:

app/product/[productID]/routeType.ts
import { type DynamicRoute } from "next-typesafe-url";
import { z } from "zod";

export const Route = {
  routeParams: z.object({
    productID: z.number(),
  }),
  searchParams: z.object({
    location: z.enum(["us", "eu"]).optional(),
    userInfo: z.object({
      name: z.string(),
      age: z.number(),
    }),
  }),
} satisfies DynamicRoute;
export type RouteType = typeof Route;

After, with paramour:

app/product/[productID]/route.def.ts
import { ,  } from "paramour";
import {  } from "zod";

export const  = ("/product/[productID]", {
  : { : .() },
  : {
    : .(["us", "eu"]).(),
    : .(.({ : .(), : .() })),
  },
});

declare const : import("paramour").;
const { search } = await .();
const search: InferSearchOutput<{
    readonly location: Codec<"us" | "eu", "optional", false, "single", boolean>;
    readonly userInfo: Codec<{
        name: string;
        age: number;
    }, "required", false, "single", boolean>;
}>

Each codec states its own wire behavior: p.number() parses the segment with a strict grammar, p.enum writes the member string, p.json keeps the zod object JSON-encoded exactly like the old library did. The codecs concept page covers the full model.

Validation becomes parsing

withParamValidation wrapped your page so validated props arrived from outside. Paramour has deliberately no wrapper: parsing is a call you make inside the component, which is also why the halves (parseParams, parseSearch) and the safe* twins exist — you choose the surface and the failure mode at the call site.

Before:

app/product/[productID]/page.tsx
import { withParamValidation } from "next-typesafe-url/app/hoc";
import type { InferPagePropsType } from "next-typesafe-url";
import { Route, type RouteType } from "./routeType";

type PageProps = InferPagePropsType<RouteType>;

async function Page({ routeParams, searchParams }: PageProps) {
  const { productID } = await routeParams;
  // ...
}

export default withParamValidation(Page, Route);

After:

app/product/[productID]/page.tsx
// @filename: app/product/[productID]/route.def.ts
import { ,  } from "paramour";

export const  = ("/product/[productID]", {
  : { : .() },
});

// @filename: app/product/[productID]/page.tsx
import type { RouteProps } from "paramour";

import {  } from "./route.def";

export default async function (: RouteProps) {
  const {  } = await .();
  return <>Product {.}</>;
}

The routes reference lists every parse surface and when to reach for each.

Errors: ZodError becomes issues

Where next-typesafe-url handed you a raw ZodError, every paramour failure is a ParamourError subclass carrying a flat issues array — every bad key reported at once, the same shape on the server, in hooks, and in standalone decoding.

Before:

const { data, isLoading, isError, error } = useSearchParams(Route.searchParams);
// error: z.ZodError | undefined

After:

import { ,  } from "paramour";

const  = ("/product/[id]", { : { : .() } });

declare const : import("paramour").;
const  = await .();
if (. === "error") {
  ..issues;
issues: readonly Issue[]
}

The errors reference documents the hierarchy.

Ready to move code? The step-by-step guide migrates a real app one route at a time.

On this page