RFC: type safe search params defined in `routes.ts` · remix-run react-router · Discussion #13800

8 min read Original article ↗

Initial Idea

Define an optional searchParams schema inside each route() entry.
Keys merge into the existing params object, giving type-safety to both href() and component props.

Example

export default [
  index("./home.tsx"),
  route("/city", "./city.tsx", {
    searchParam1: z.string(),
    page: z.number().min(1),
  }),
] satisfies RouteConfig;

When user is already using flat routes you can augment the existing fs routes with ones with the search params, react router will deduplicate entries with the same path:

export default flatRoutes().then((fsRoutes) => {
    return [
        ...fsRoutes,
        route("/city", "./city.tsx", { searchParam1: z.string() }),
    ] satisfies RouteConfig
})

Usage

href("/city", { searchParam1: "x" });

function Page({ params }: Route.ComponentProps) {
  const { searchParam1, } = params
}

Motivation

  • Removes brittle, stringly-typed query handling (e.g., OAuth callbacks)
  • Type-safe href() creation and component access
  • Supports arrays (repeat keys), numbers, emails, etc.

Validation

Powered by standard-schema, so any validator (Zod, Yup, Ajv, …) can be plugged in.

Error Handling

Validation failures bubble to the route’s existing error boundary.

Scope & Compatibility

  • Fully opt-in: no searchParams → current behavior
  • Name-collision check keeps path params intact
  • Not applicable to data-router or declarative <Routes> mode

FAQ

Question Answer
Why reuse params instead of adding searchParams? Smaller surface; keeps href() and component props unchanged.
Can I swap validation libraries? Yes—standard-schema is just an adapter layer.
What data types are supported? Anything jsonschema can express—numbers, arrays, enums, email, etc.
What happens on validation error? Your route’s error boundary renders.
Does this touch declarative routes? No—those modes stay exactly as they are.

Blockers

To extract the typescript code from the params schema defined using standard-schema we need to wait for jsonschema support in standard-schema first, or only support Zod initially.

You must be logged in to vote

Name-collision check keeps path params intact

Can you elaborate on that? My first question was going to be what React Router should reasonably do when a dev specifies a path param with the same name as a search param?

You must be logged in to vote

1 reply

@remorses

Path params will have precedence. Collisions is very simple to prevent: name the query params differently than path params

Or react-router could throw an error if a query param schema has same name as a path param

The routes.ts file is not part of the app code, is part of the config used to build the app code. I don't think using Zod (or another validation lib) there makes sense, because then RR needs to extract those schemas to validate at runtime, where right now the result of routes.ts is used only at build time.

I think if you want to use runtime validation for search params this needs to be defined in the route module.

What could go in routes.ts is probably a list of accepted search params, something like:

route(path, file, {
  id: "route-id",
  searchParams: ["page", "order", "query"]
})

Then the router could use this to extract from the URL those search params, and you can receive them in a loader like this:

export async function loader({ request, params, context }: Route.LoaderArgs {
  params.routeParam // this is part of the pathname, typed as `string` (unless marked as optional)
  params.page // from search params, typed as `string | null`
  params.order // same as page
  params.query // same as page
}

You will still get string | null as using URLSearchParams (as this is the most correct value), if you want to be more strict and cast them, you can then use Zod

const SearchParamsSchema = z.object({
  page: z.coerce.number().nullable(),
  order: z.enum(["asc", "desc"]).nullable(),
  query: z.string().nullable()
})

export async function loader({ params }: Route.LoaderArgs) {
  let searchParams = SearchParamsSchema.parse(params)
  searchParams.page // this is now `number | null`
  searchParams.order // this is now `"asc" | "desc"`
  searchParams.query // this is still `string | null`
}
You must be logged in to vote

2 replies

@sergiodxa

Also maybe there should be a separate searchParams object from Route.LoaderArgs (and ActionArgs, ComponentProps, etc.) so there's no name collision between route params and search params. Maybe in a future version params can be renamed to routeParams or pathParams or something similar.

@sergiodxa

Another option instead of manually returning search params is to extend the route modules to accept an optional ValidateSearchParamsFunction that receives the URLSearchParams instance, use any technique to validate them and return a strongly typed object, this object is then used as type for LoaderArgs, etc.

export function validateSearchParams(searchParams: URLSearchParams) {
  return SearchParamsSchema.parse(Object.fromEntries(searchParams)) // here you can use Zod
}

export async function loader({ request, params, searchParams, context }: Route.LoaderArgs) {
  // searchParams here is strongly typed based on the ReturnType of validateSearchParams
}

export default function Component({ searchParams }: Route.ComponentProps) {
  // searchParams here is strongly typed based on the ReturnType of validateSearchParams
}

My main concern with the JS-first approach is that JS objects are not strictly compatible with the application/x-www-form-urlencoded content type, which is the format for URL search params.

In particular, this is a perfectly valid querystring:

However, trying to map that to a JS object using Object.fromEntries(new URLSearchParams(location.search)) is going to give you the object {name: "bar" }, which leads to data loss. IMO that's unacceptable behavior for a framework.

A correct way of parsing/validating search params directly would be something like:

const SearchParamsSchema = z.array(
  z.union([
    z.tuple([z.literal('category'), z.string()]),
    z.tuple([z.literal('page'), z.coerce.number()]),
    z.tuple([z.literal('page_size'), z.coerce.number()]),
    z.tuple([z.string(), z.string()]), // catch everything else
  ])
);

But that's... yeah. Not the prettiest or most pleasant to work with.

My two cents: This mismatch presents challenges that are not within the scope of React Router's problem space. The platform has usable low-level abstractions (URL, URLSearchParams, etc) that React Router can and should rely on. Any additional higher level abstractions have so many possible permutations that they're better left to userland.

(I am open to changing my mind though!)

You must be logged in to vote

4 replies

@remorses

I think it's fine, if you want to support the use case of passing multiple times the search parameters you can do so explicitly in the schema. If not only the last search parameter would be used. This is what the developer would do anyway using URLSearchParams object, either you expect an array and use getAll or only one item and use get.

The benefit is that you can create paths for redirects type safely using href

@rossipedia

I think it's fine, if you want to support the use case of passing multiple times the search parameters you can do so explicitly in the schema.

I think that's the crux of what I'm asking: How would that be done, specifically? There are quite a few methods out there for specifying multiple values with the same key, aside from the standard application/x-www-form-urlencoded one:

  • ?tag.0=foo&tag.1=bar
  • ?tag[]=foo&tag[]=bar
  • ?tag[0]=foo&tag[1]=bar
  • ?tag=foo,bar
  • etc...

Each one has their own trade-offs, none are quite what I'd call standard. There's also the issue of how to represent non-string values.

For instance, would page=1 deserialize to { page: number } or { page: string }? Likewise for completed=true or completed=false. If RR were to try to coerce those values, what would the escape hatch look like if you needed the raw values? Just handle the search params yourself? That's a valid strategy, for sure, but it'd be beneficial to have those edge-cases covered in the proposal.

There's also the question about multiple route segments in a matched URL defining the search params they care about. How does that behave? Ideally you'd merge all those types together like TParams1 & TParams2, though you'd have to get creative to resolve naming conflicts in the type definition too, since the intersection type of two disparate primitives is never (ie: type T = number & boolean).

In order for this to be viable, React Router would have to take an opinion on what converting location.search to a JS value would look like in a way that doesn't compromise on correctness, and also in a way where both the consuming side (ie: the route that's defining the schema) and the producing side (ie: code that's generating a URL for that target route) have sane and type-safe defaults. The producing side would have to account for all the matched route segments that make up the target URL.

I may have argued myself out of being against this, and am more now leaning towards "ok, what does this look like and how to address the edge cases?" 😅

@remorses

To solve these problems I would

  1. Only allow top level fields in the schema
  2. Only allow string and array of strings in the schema types
  3. For expressing array items repeat the same query param multiple times instead of using []

@sergiodxa

At that point you don't need Zod anymore, it would be easier to just provide a list of the params names you expect and type them as string | string[] | null.