Opening: Why param matchers matter
Route parameters in web applications are a double-edged sword: they're essential for dynamic pages, but they also require validation. Param matchers: a Svelte & SvelteKit guide for developers addresses a problem every team faces—how do we ensure that only valid route segments actually load a page component? Without param matchers, you end up scattering validation logic across your load functions, pages, and handlers. With them, you gain a declarative, type-safe layer that stops invalid routes at the routing level.
Understanding param matchers in SvelteKit
A param matcher is a file in src/params/ that exports a function testing whether a route parameter matches a specific pattern. SvelteKit calls this function during routing—if it returns true, the route matches; if false, SvelteKit continues looking for other routes or shows a 404. This happens before your component or load function runs.
The matcher function receives the parameter value as a string and returns a boolean. That's it. Simple, but powerful when layered into your routing strategy.
// Example matcher signature
export function match(param: string): boolean {
return /^\d+$/.test(param);
}
Most teams build matchers for common patterns: integers, UUIDs, slugs, and domain-specific identifiers. Once defined, they become reusable across your entire routing tree.
Creating src/params/integer.ts
Let's build a real example. In an e-commerce app, you need product IDs to be integers. If someone hits /products/abc, that shouldn't match a route expecting an integer product ID.
// src/params/integer.ts
export function match(param: string): boolean {
return /^\d+$/.test(param);
}
That's a complete matcher. It uses a regex to ensure the param contains only digits. No leading zeros, no letters, no special characters.
For tighter control, some teams add boundary checks:
// src/params/integer.ts with bounds
export function match(param: string): boolean {
const num = Number(param);
return Number.isInteger(num) && num > 0 && num <= 999999;
}
This matcher now validates that the param is a positive integer within a reasonable range. Adjust the bounds to match your business logic. If you're dealing with database IDs that can be large, you might skip the upper bound or use BigInt logic.
Once your matcher exists, SvelteKit automatically makes it available. You don't need to import or register it anywhere.
Using [id=integer] in folder names
Now that the matcher exists, apply it to your routes using the bracket notation: [id=integer]. This tells SvelteKit to use the integer matcher for the id parameter.
Suppose you have a route structure like:
src/routes/
products/
[id=integer]/
+page.svelte
+page.server.ts
Now /products/42 matches and loads your component. But /products/abc, /products/3.14, and /products/0 do not. SvelteKit rejects them before your load function runs.
This is cleaner than the old approach:
// Old way: manual validation in load
export async function load({ params }) {
const id = parseInt(params.id, 10);
if (isNaN(id) || id <= 0) {
throw error(404, 'Product not found');
}
// ... fetch product
}
With matchers, the invalid route never reaches your load function. SvelteKit's router handles it.
You can combine matchers in a single route:
src/routes/
orders/
[customerId=integer]/
[orderId=integer]/
+page.svelte
Both parameters are validated before your component loads.
Building matchers for common patterns
Beyond integers, your team likely needs UUID, slug, and domain-specific matchers. Here are production-ready examples:
// src/params/uuid.ts
export function match(param: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(param);
}
// src/params/slug.ts
export function match(param: string): boolean {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(param);
}
// src/params/email.ts (for routes that take email as param)
export function match(param: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(param);
}
Store these in src/params/ and reference them in your route folders. If your team shares patterns across projects, consider extracting matchers into a shared package and importing them—though SvelteKit's built-in discovery makes this less critical than it sounds.
Unmatched routes fall through
An important behavior: when a param doesn't match, SvelteKit doesn't immediately error. Instead, it tries the next route in the tree. This is powerful for layered routing.
Consider this structure:
src/routes/
[slug]/
+page.svelte // Catches anything
products/
[id=integer]/
+page.svelte // Catches only integers
If you request /products/abc, the integer matcher fails. SvelteKit continues looking and eventually matches the root [slug] route, which loads that component instead. Your slug route then handles abc as a catch-all.
This fallthrough behavior means you can layer validation. Specific matchers first, generic catch-alls last. It's elegant and requires no manual routing logic.
However, order matters. If you reverse the folder structure, [slug] matches everything, and [id=integer] never gets a chance. Always put specific matchers in more nested or earlier-loaded paths.
Cleaner than manual checks
Let's compare the before and after in a realistic scenario. You're building an order management system for a SaaS app.
Without matchers:
// src/routes/customers/[customerId]/orders/[orderId]/+page.server.ts
export async function load({ params, fetch }) {
// Validate customer ID
const customerId = parseInt(params.customerId, 10);
if (isNaN(customerId) || customerId <= 0) {
throw error(400, 'Invalid customer ID');
}
// Validate order ID
const orderId = parseInt(params.orderId, 10);
if (isNaN(orderId) || orderId <= 0) {
throw error(400, 'Invalid order ID');
}
// Now fetch and authorize
const order = await fetch(`/api/customers/${customerId}/orders/${orderId}`);
if (!order.ok) {
throw error(order.status, 'Order not found');
}
return { order: await order.json() };
}
With matchers:
// src/routes/customers/[customerId=integer]/orders/[orderId=integer]/+page.server.ts
export async function load({ params, fetch }) {
// No validation needed; SvelteKit already confirmed both are integers
const order = await fetch(`/api/customers/${params.customerId}/orders/${params.orderId}`);
if (!order.ok) {
throw error(order.status, 'Order not found');
}
return { order: await order.json() };
}
The second version is shorter, simpler, and more maintainable. You've moved validation to the routing layer where it belongs. Your load function focuses on fetching and authorization, not param validation.
This separation of concerns pays dividends as your app grows. If you later need to change the validation logic, you update one matcher file. All routes using that matcher inherit the change automatically.
Typing params with matchers
SvelteKit doesn't automatically narrow TypeScript types based on matchers, but you can improve type safety with a simple pattern:
// src/lib/types.ts
export type IntParam = string & { readonly __brand: 'IntParam' };
export function asIntParam(value: string): IntParam {
return value as IntParam;
}
Then in your load function, you can explicitly cast after the matcher has validated:
// src/routes/products/[id=integer]/+page.server.ts
import { asIntParam } from '$lib/types';
export async function load({ params }) {
const id = asIntParam(params.id);
// Now id has type IntParam, signaling to other developers that it's been validated
const product = await db.product.findById(parseInt(id, 10));
return { product };
}
This is optional but helpful for teams that rely heavily on TypeScript for documentation and safety.
Common mistakes
Mistake 1: Relying on matchers for security. Matchers validate format, not authorization. Never assume that because a param matched your UUID matcher, the user can access that resource. Always authorize in your load function or server action.
Mistake 2: Overly restrictive matchers. A matcher that's too strict will cause confusion. If your API accepts UUIDs with or without hyphens, your matcher should too. Test edge cases before deploying.
Mistake 3: Forgetting matcher priority. If you have [id=integer] and [id] in the same folder, the integer matcher takes precedence only because it's more specific. For clarity, don't mix matchers and unmatched params at the same level.
Mistake 4: Not documenting custom matchers. If you build a domain-specific matcher (e.g., tenantId or sku), add a comment explaining what it validates. Future maintainers will thank you.
Key takeaways
- Param matchers are functions in
src/params/that validate route parameters declaratively. - Use bracket notation like
[id=integer]to apply matchers to route segments; invalid params cause SvelteKit to continue routing rather than loading your component. - The fallthrough behavior lets you layer matchers—specific ones first, catch-alls last—for flexible routing without manual logic.
- Matchers reduce boilerplate in load functions and server actions, keeping your code focused on business logic.
- Always add authorization checks in your load function or server action; matchers only validate format, not permissions.
- Build a small library of reusable matchers (integer, UUID, slug) and keep them close to your routes for easy discovery.






Comments
Loading comments…