Every Vue app I’ve worked on has this line somewhere:
const id = Number(route.params.id)
And next to it, an if (Number.isNaN(id)) that nobody tests.
Eduardo San Martin Morote (@posva), the author of Vue Router and Pinia, gave a talk called Type-Safe State in Your URL at Vue.js Amsterdam. It’s about killing that line. The experimental Vue Router can parse URL params into real types before your component ever sees them. These are my notes, plus the actual API from the Vue Router 5.4 source.
Typesafe State in Your URL, Vue.js Amsterdam
We have a lot of ways to store state in Vue:
// Local reactive state
const user = ref<User>()
// Global shared state
const useUserStore = defineStore('user', () => {
const user = shallowRef<User | null>(null)
return { user }
})
// Persisted state
const token = useLocalStorage('token', '')
// Time-travel state
const counter = ref(0)
const { undo, redo } = useRefHistory(counter)
The URL is one more, and we tend to forget it. It has three parts, and each one stores something different:
https://example.com/products/42?sort=price&page=3#reviews
└── path ──┘└──── query ─────┘└ hash ┘
- Path identifies the resource:
/products/42 - Query holds filters, sorting and pagination:
?sort=price&page=3 - Hash is client-only state:
#reviews
The hash never reaches the server. Most apps use it for scroll position, but you can put real data in it. The Vue repo does this for bug reports: the Vue Playground stores the whole reproduction’s code in the hash, so an issue link is the reproduction.
The URL is also the only state the user can see. Nobody opens your devtools to look at a Pinia store. Everyone can read, edit and copy the address bar.
Eduardo made three points about why URL state is special. I think all three are underrated.
- It teleports. Pinia, refs, localStorage: none of them are shareable. You can’t send your Pinia store to a colleague. You can send a URL.
- Time travel is built in. Back and forward are undo and redo. You get them for free.
- It bridges server and client. The server can’t read your Pinia store, but it can read the URL. A login flow is the classic example:
GET /dashboard→302 /login?redirect=/dashboard→ after login, back to/dashboard.
Everything in the URL is a string. JavaScript is happy to work with strings in ways you didn’t ask for:
route.query.page // "3"
page + 1 // "31" 😬
route.query.admin // "false"
if (admin) { /* ... */ } // truthy! 😱
route.query.price // "9.99"
price + tax // "9.990.2" 💸
The admin one is the scary one. "false" is a non-empty string, so it’s truthy.
With the stable router, route.params.id is typed as string | string[]. Typed routes from file-based routing narrow that to string, but you still don’t get a number. So you parse it yourself:
const id = Number(route.params.id)
if (!Number.isNaN(id)) {
// do stuff
} else {
// error handling?
}
Query params are worse. The router doesn’t match them at all, so the type is string | null | (string | null)[]. Reading a page number safely looks like this:
const page = Array.isArray(route.query.page)
? 1 // reject arrays, use default
: Number.isInteger(Number(route.query.page))
? Number(route.query.page)
: 1 // reject invalid numbers, use default
Nobody writes this. We all write Number(route.query.page) || 1 and hope.
Put it in a real component and the parsing takes over the template:
<script setup lang="ts">
const route = useRoute()
// All of this just to read params safely
const id = computed(() => {
const value = Number(route.params.id)
if (!Number.isInteger(value)) {
return new TypeError('Invalid product ID')
}
return value
})
</script>
<template>
<template v-if="typeof id === 'number'">
<ProductDetails :id />
<ProductReviews :product-id="id" />
</template>
<ErrorBox v-else :error="id" />
</template>
Every page that reads a param repeats some version of this. And every page does it a little differently.
Eduardo called this noise, for humans and for the AI reading your component. Half the file is parsing, and none of it is the page’s job. The router already matches the URL. Why not let it validate too?
Here is the same page with the experimental router:
<script setup lang="ts">
const route = useRoute()
// route.params: { id: number }
</script>
<template>
<ProductDetails :id="route.params.id" />
<ProductReviews :product-id="route.params.id" />
</template>
No computed, no Number(), no error branch. route.params.id is a number, both at runtime and in TypeScript. If the URL is /products/abc, this page never renders, because the route doesn’t match.
Two details I like here. First, useRoute() takes no argument. With file-based routing it already knows which page it’s called from, so the params are typed for that page. Second, path params and query params end up in the same route.params object. Your page doesn’t care whether page came from the path or the query. It’s just typed input, like props.
One caveat Eduardo raised: moving a param from the path to the query changes your URLs. Old links break, so treat that as a breaking change.
That’s the whole pitch. The rest is how you get there.
The experimental router has a new matching layer called a resolver. Each route doesn’t just say “this path goes to this component”. It also says how to turn each piece of the URL into a value, and back.
Eduardo showed what a route record looks like at that level. It’s a bit much:
import { /* ... */ } from 'vue-router/experimental'
const routeProfilesDetail = normalizeRouteRecord({
name: 'profiles-detail',
components: { default: () => import('../pages/profiles/[userId].vue') },
path: new MatcherPatternPathDynamic(
/^\/profiles\/([^/]+)$/i,
{ userId: [PARAM_PARSER_INT] },
['profiles', 1]
),
query: [
new MatcherPatternQueryParam('page', 'p', 'value', PARAM_PARSER_INT, 1),
],
})
His slide right after this one said “what the f*ck? 🥴”. Fair.
The good news: you never write this. As Eduardo put it, the code is generated “not by a non-deterministic AI, but by a deterministic plugin.” The positional arguments look terrible because they compress well and run fast. The Vite plugin builds all of this from your pages/ folder. You just tell it which params need which parser.
Vue Router 5 merged unplugin-vue-router into Vue Router, so file-based routing is now part of the core package and the plugin comes from vue-router/vite. Eduardo stressed that v5 has no breaking changes, so the upgrade is safe. Nuxt should ship it out of the box in its next version.
It’s still an unplugin under the hood, so it works with other bundlers too. Here’s Vite:
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import VueRouter from 'vue-router/vite'
export default defineConfig({
plugins: [
VueRouter({
experimental: {
paramParsers: true, // scans src/params by default
},
}),
vue(),
],
})
If you already use file-based routing, you import routes from vue-router/auto-routes. The experimental router takes a generated resolver from vue-router/auto-resolver instead:
// src/router/index.ts
import { createWebHistory } from 'vue-router'
import { experimental_createRouter as createRouter } from 'vue-router/experimental'
import { resolver, handleHotUpdate } from 'vue-router/auto-resolver'
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
resolver,
})
if (import.meta.hot) {
handleHotUpdate(router)
}
One gotcha from the docs: the experimental router doesn’t register <RouterLink> and <RouterView> for you. Add app.component('RouterLink', RouterLink) and app.component('RouterView', RouterView) in main.ts.
The shortest way is to rename the file. Add =parser to the param:
src/pages/products/[id].vue → route.params.id: string
src/pages/products/[id=int].vue → route.params.id: number
If you don’t want to rename files, declare the parser in definePage() instead:
<!-- src/pages/products/[id].vue -->
<script setup lang="ts">
definePage({
params: {
path: { id: 'int' },
},
})
</script>
Query params always go through definePage(), because they aren’t part of the file name:
<!-- src/pages/search.vue -->
<script setup lang="ts">
definePage({
params: {
query: {
page: { parser: 'int', format: 'value', default: 1 },
tag: { parser: 'string', format: 'array' },
},
},
})
const route = useRoute('/search')
// route.params.page: number
// route.params.tag: string[]
</script>
format: 'value' takes the last value if the URL repeats the key. format: 'array' turns ?tag=a&tag=b into ['a', 'b']. The default kicks in when the param is missing or fails to parse.
Vue Router ships three parsers out of the box: int, bool and string. string is the default and does nothing. Eduardo said he isn’t sure the built-ins will survive to v6. He thinks they will, but this is all still experimental.
definePage() is a macro. It runs at build time and is stripped from the bundle.
This is the part I like most. Drop a file into src/params/, and the file name becomes the parser name:
src/params/
date.ts → [when=date].vue
semver.ts → [version=semver].vue
A parser is a get (string → value) and a set (value → string):
// src/params/date.ts
import { defineParamParser, miss } from 'vue-router/experimental'
export const parser = defineParamParser<Date>({
get: (value) => {
const date = new Date(value)
if (isNaN(date.getTime())) {
miss(`Invalid date: "${value}"`)
}
return date
},
set: (value) => value.toISOString().split('T')[0] ?? '',
})
miss() tells the router “this value doesn’t match”. It just throws an error, and any thrown error works the same way. miss() only gives you a nicer message for debugging. For a path param, the router skips the route and tries the next one, so a catch-all 404 page can pick it up. Matching doesn’t stop, it falls through.
You only write validation in get. When the router builds a URL from your values, it runs set and then get on the result. A bad value from set fails in get, so the validation lives in one place.
get doesn’t have to return a primitive. A Date, a parsed semver object or a class instance all work.
defineParamParser handles optional and repeatable params for you. The same int parser works for [id=int].vue, [[id=int]].vue (optional, gives number | null) and [ids=int]+.vue (repeatable, gives number[]).
Eduardo’s demo app was a Windows XP themed shop for nostalgic software from the 2000s (WinRAR at $29, Winamp for free). He refactored a few pages live.
Products. Before, /products/abc still matched the product page. The page turned "abc" into NaN, found nothing and showed its own “not found”. That’s a 404, but the wrong kind. He renamed [productId].vue to [productId=int].vue, deleted the Number() and isNaN checks, and /products/abc became a real router 404.
Events. events/[when].vue did new Date(route.params.when), an isValid computed and error handling in both script and template. After the rename to [when=date].vue, all of that went away. One thing broke: a router.push() that built the path from a text input. Inputs give you strings, not dates. His fix was a relative location: pushing ./ plus the new value replaces the last segment of the current URL. An invalid date now goes straight to the 404.
Search. search.vue had no path params at all, only query: q, page, year and price filters. Each one was a cast or a Number(route.query.x) || fallback. He moved q into definePage() by hand. At first it was typed string | undefined. Adding default: '' made it always a string.
Then he gave Codex the rest: “refactor the query params like I did for q and page.” It moved every filter into definePage(), switched route.query to route.params, and the page kept working with no type errors. The only mistake: it added an import for definePage, which is a macro and needs no import. His point was that the syntax is so plain that an LLM gets it right on the first try. I’ll take that as a strong argument for declarative config over hand-written parsing.
The pattern is the same every time. Parsing logic leaves the component and moves into a parser you write once and reuse.
Path params and query params fail differently. I think this is the most important design decision in the whole feature.
Path params are strict. /profiles/abc with an int param doesn’t match. You get a 404, or the next route that does match.
Query params are resilient. ?page=abc still matches the route. The param falls back to its default (or undefined if there isn’t one). Use required: true if you want a missing query param to reject the match instead.
Either way, the page always renders with valid, typed data. That’s the guarantee you’re paying for. And the split fits how we already use URLs: the path holds what the page needs, the query holds what’s optional.
Eduardo also pushed back on the obvious next thought: “great, now I’ll put all my state in the URL”. Two problems.
His slide had an e-commerce URL with brands, colors, materials, five UTM params, an A/B test variant and a list of recently viewed IDs. It filled the whole screen. Keep URLs under roughly 2,000 characters if you want them to work everywhere. That limit comes from Internet Explorer, and Eduardo thinks Edge still follows it. Nobody wants to share a 2,000-character link anyway. Put the things a user would want to share or bookmark in the URL. Keep the rest in Pinia.
A crawler sees every distinct URL as a page:
/products?ref=banner → page A
/products?utm_source=email → page B
/products?gclid=abc123 → page C
Same content, three pages competing in the index. The fix is a canonical URL. Strip the cosmetic params (ref, utm_*, gclid) and point them at /products. Keep the meaningful ones (?page=2, ?sort=price) as their own canonical. Eduardo said people forget this a lot.
No date. You can try everything from this post today in Vue Router 5, under vue-router/experimental (setup docs). Eduardo was clear that it’s not production-ready: try it in toy projects and side projects.
What’s still missing before v6:
- Automatic canonical URL generation
- Nested aliases, which don’t work yet
- Dynamic routing. The resolver he showed is static, built at compile time
- A few tracked bugs
One number from the talk did surprise me. Custom resolvers make the router smaller:
| Before | After | Change | |
|---|---|---|---|
| Minified | 23.06 KB | 16.94 KB | -27% |
| Brotli | 8.18 KB | 6.19 KB | -24% |
The reason: route parsing moved from runtime to build time. That’s about 6 KB brotli, and it includes <RouterLink> and <RouterView>. Add data loaders and the size goes up, but Eduardo expects the savings to stay close to 30%, because the extra work stays at build time.
You get more types and less JavaScript.
Eduardo’s last slide summed it up nicely:
- Past: raw strings, no safety.
route.params.idisstring | string[]. - Present: typed routes and manual parsing.
route.params.idisstring, and you reach forNumber(),isNaN()orz.codec(). - Future: file-based routing and clean pages.
[when=date].vuegives youroute.params.whenas aDate.
His advice for getting ready: move to typed routes with file-based routing now. If you use Zod or Valibot, try to write bidirectional validators, which Zod calls codecs. Standard Schema doesn’t support that yet, and Eduardo hopes it will, because a one-way schema can’t build URLs. Most of the time you can write the two functions by hand anyway.
I think this is the right direction. URL parsing is one of those things every team solves again, slightly wrong, in every component. Moving it into the router, with one parser per type and real TypeScript types on the other side, is the kind of boring fix that makes a codebase better.
Don’t use it in production yet. But if you start a new project with file-based routing, name your files [id=int].vue now. Your file names will already say what each param is.