Step-by-Step Guide
Migrate route by route with both libraries installed.
This guide migrates an app incrementally: both libraries stay installed,
each route moves over one at a time, and next-typesafe-url is removed
only at the end. The app builds and runs at every step — there is no
broken intermediate state, and you can ship mid-migration.
The steps were executed literally, in this order, against a real
next-typesafe-url v6.1.0 app on Next 15 before being written down.
URLs change per route
As each route migrates, the URL format of its object-valued search params changes at that moment — not at the end. Step 6 is the decision point; read it before migrating any route whose search params carry objects or arrays.
Step 1: Install paramour alongside
npm install paramour @paramour-js/nextThere is no peer conflict: paramour does not require zod, so your
existing zod install keeps serving next-typesafe-url and doubles as
refinement schemas inside codecs. One version note: next-typesafe-url
declares support through Next 15 while @paramour-js/next supports
Next 15 and up — so the migration happens on Next 15, and upgrading to
Next 16 becomes available the moment the old library is gone.
Step 2: Run both codegens side by side
npx paramour initinit scaffolds paramour.config.ts, wraps your next.config with
withTypedRoutes, adds a
paramour script, and runs the first generate. All of it coexists with
next-typesafe-url: the two artifacts have different filenames
(paramour-env.d.ts vs _next-typesafe-url_.d.ts) and augment different
modules, so neither build step interferes with the other.
{
"scripts": {
"dev": "concurrently \"next-typesafe-url -w\" \"next dev\"",
"build": "next-typesafe-url && next build",
"paramour": "paramour generate"
}
}Your existing next-typesafe-url watcher keeps running exactly as
before; withTypedRoutes regenerates the paramour artifact inside
next dev and next build, so no second watcher is needed. The
CLI guide covers the commands in depth.
Step 3: Pick a route and translate its definition
Start with a leaf route — something few pages link to. The translation is mechanical: each zod schema field becomes a codec.
Before:
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:
import { , } from "paramour";
import { } from "zod";
export const = ("/product/[productID]", {
: { : .() },
: {
: .(["us", "eu"]).(),
// p.json preserves the old URL format — step 6 covers the alternative
: .(.({ : .(), : .() })),
},
});The recurring translations, in one place:
| zod pattern | codec |
|---|---|
z.number() / z.string() / z.boolean() | p.number() / p.string() / p.boolean() |
z.enum([...]) | p.enum([...]) |
.optional() / .default(v) | .optional() / .default(v) |
z.object(...) / z.array(...) values | p.json(schema) — or flatten, see step 6 |
Date via z.string().transform(...) | p.isoDate() or p.timestamp() |
Catch-all z.array(z.string()) | An element codec: slug: p.string() |
Keep routeType.ts around for now — it still serves any $path call
sites you haven't reached yet. The concept map
has the full vocabulary table, and the
p.* reference the full catalog.
One structural note for the Pages Router: next-typesafe-url defined
Route inside the page file, but every file in pages/ is a page, so
paramour route objects for Pages routes live in a shared module instead
(say lib/routes.ts) using definePagesRoute — same config shape.
Step 4: Swap the links
Before:
import { $path } from "next-typesafe-url";
const link = $path({
route: "/product/[productID]",
routeParams: { productID: 23 },
searchParams: { userInfo: { name: "bob", age: 23 }, location: "us" },
});After:
const = (, {
: { : 23 },
: { : "us", : { : "bob", : 23 } },
});
link;Do this app-wide for the migrated route now: grep for its string key
("/product/[productID]") to find every $path call site. A call site
still on $path keeps producing old-format URLs for this route. Once the
last one is gone, delete the route's routeType.ts and re-run both
codegens. See the href reference for what
the branded return type buys.
Step 5: Replace withParamValidation with a parse call
Before:
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;
const { userInfo } = await searchParams;
return <h1>Product {productID}, viewed by {userInfo.name}</h1>;
}
export default withParamValidation(Page, Route);After — no wrapper; the page parses its own props:
// @filename: app/product/[productID]/route.def.ts
import { , } from "paramour";
import { } from "zod";
export const = ("/product/[productID]", {
: { : .() },
: {
: .(.({ : .(), : .() })),
},
});
// @filename: app/product/[productID]/page.tsx
import type { RouteProps } from "paramour";
import { } from "next/navigation";
import { } from "./route.def";
export default async function (: RouteProps) {
const = await .();
if (. === "error") ();
const { , } = .;
return <>Product {.}, viewed by {..}</>;
}Where you previously caught a ZodError, you now branch on
result.status — or use parse and let the error reach your nearest
error.tsx. The defining-routes guide
compares the two, and the
routes reference lists the halves
(parseParams / parseSearch) when a layout or generateMetadata needs
only one side.
RouteProps passes Next 15's build-time page check as written above (its
members are promise-only, exactly what the generated .next/types check
requires); Next's own generated PageProps<"/product/[productID]"> also
works if you prefer it — either flows into safeParse unchanged.
Step 6: Decide the URL story for this route
next-typesafe-url wrote scalar search params conventionally and
JSON-encoded everything object- or array-valued. Scalars therefore carry
over untouched — a route like /search?q=hi&page=2 parses identically
before and after migration. The decision is only about the JSON-blob
keys, and it's per key:
Preserve — keep p.json(schema) for each formerly-object key, as in
step 3. Old bookmarked and shared URLs keep working; the wire stays
JSON. This is the zero-risk default, and you can revisit it later.
Modernize — flatten the object into per-key codecs. URLs become human-readable, but old links to this route stop parsing:
preserve: /product/23?location=us&userInfo=%7B%22name%22%3A%22bob%22%2C%22age%22%3A23%7D
modernize: /product/23?age=23&location=us&name=bobOld links break here
If a modernized route had object-valued URLs shared in the wild, add a
redirect in middleware.ts that rewrites the old shape before it
reaches the route. A route that never leaked such URLs (internal
navigation only) can modernize freely — every href call site is
already emitting the new format.
Two smaller wire changes apply either way. Paramour elides value-form
defaults — page: 1 with .default(1) never appears in the URL, where
the old library serialized it. And paramour's grammars are strict:
p.isoDate() accepts ISO dates only, where a z.string().transform()
quietly accepted anything new Date() could chew on. Both are covered in
differences, and the
wire-format spec states every rule.
Step 7: Migrate the client hooks
Before:
"use client";
import { useSearchParams } from "next-typesafe-url/app";
import { Route } from "./routeType";
export function SearchStatus() {
const { data, isLoading, isError, error } = useSearchParams(Route.searchParams);
if (isLoading) return <p>Loading…</p>;
if (isError) return <p>Bad search params: {error.message}</p>;
return <p>q={data.q}</p>;
}After — the hook takes the whole route and returns a two-arm
SafeResult:
// @filename: app/search/route.def.ts
import { , } from "paramour";
export const = ("/search", {
: { : .().(1), : .() },
});
// @filename: app/search/search-status.tsx
"use client";
import { } from "@paramour-js/next/app";
import { } from "./route.def";
export function () {
const = ();
if (. === "error") {
return < ="alert">{..}</>;
}
return <>q={..}</>;
}There is no isLoading branch to write: App-Router params are
synchronously available and SSR-consistent, so the loading arm your old
components carried is dead code after migration
(why). Pages
Router components keep a genuine third state — status: "pending"
replaces isLoading before the router is ready. The
hooks guide and
Pages hooks reference cover both
routers.
Migrating from next-typesafe-url's /pages entry?
If your old next.config carried
transpilePackages: ["next-typesafe-url"] to work around its
Next 15 build failure (ERR_MODULE_NOT_FOUND for next/router at
page-data collection), drop the entry when the migration removes the
package — @paramour-js/next/pages builds on Next 15 without any
transpilePackages workaround.
Step 8: Repeat per route, in this order
- Leaf routes first — few inbound links means few
$pathcall sites to chase per route. - Object-valued search params last — those routes need the step 6 decision; scalar-only routes migrate mechanically.
- Catch-alls translate directly — an element codec on the segment, nothing special (defining routes).
During the whole migration, paramour list doubles as the checklist:
filesystem routes without a route.def.ts are flagged
filesystem only (no route definition found), so the remaining work is
always one command away.
Step 9: Remove next-typesafe-url
Once paramour list shows every route defined and no $path call sites
remain:
npm uninstall next-typesafe-urlThen sweep the config surface — this grep list caught every leftover in our test migration:
_next-typesafe-url_.d.ts— delete the generated artifact, and drop it fromtsconfig.json'sinclude.package.json— removenext-typesafe-urlfrom thedevandbuildscripts (theconcurrentlywrapper usually goes with it; a paramour build gate isparamour check && next build— see check).next.config— removenext-typesafe-urlfromtranspilePackagesif you had it there.- Source: grep for
$path(,withParamValidation,InferRoute, andnext-typesafe-url/imports — all should be gone.
Finish with a health check:
npx paramour doctordoctor verifies the config, the artifact, the withTypedRoutes wrap,
and the tsconfig include in one pass
(reference).
Where next
- Differences — the full list of behavioral deltas, including what paramour deliberately doesn't have.
- nuqs adapter — client-side search-param writing
(
useState-for-the-URL), somethingnext-typesafe-urlnever offered. - CLI workflows —
paramour checkas a CI gate so route drift fails the build.