A powerful Vue 3 composable for type-safe URL query parameter synchronization with Zod validation, automatic state management, and intelligent default handling.
- đź”’ Type-safe: Full TypeScript support with Zod schema validation
- 🔄 Bidirectional sync: Automatic synchronization between component state and URL
- 🎯 Deep object support: Handles nested objects and arrays with proper serialization
- ⚡ Performance optimized: Batched updates with async processing
- đź§ą Smart cleanup: Automatically removes default values from URL
- 🔌 Vue Router integration: Seamless integration with Vue Router
- 🎨 Flexible API: Support for single values or complex objects
- đź”— Nested path support: Deep object structures automatically transformed to dot notation
- 🔄 Instance sync: Multiple instances with the same key stay synchronized
- 📜 History control: Choose between push and replace modes for router navigation
- @chronicstone/vue-route-query
# npm
npm install @chronicstone/vue-route-query zod vue-router
# yarn
yarn add @chronicstone/vue-route-query zod vue-router
# pnpm
pnpm add @chronicstone/vue-route-query zod vue-router
# bun
bun add @chronicstone/vue-route-query zod vue-router
import { useRouteQuery } from '@chronicstone/vue-route-query'
import { z } from 'zod'
// Single value with key
const activeLayout = useRouteQuery({
key: 'layout',
schema: z.enum(['table', 'grid']),
default: 'table' // Won't appear in URL when value is 'table'
})
// Type: Ref<'table' | 'grid'>
const filters = useRouteQuery({
schema: {
search: z.string(),
status: z.array(z.string()),
date: z.object({
from: z.string(),
to: z.string()
})
},
default: {
search: '',
status: [],
date: { from: '', to: '' }
}
})
// Type: Ref<{ search: string; status: string[]; date: { from: string; to: string } }>
const userSettings = useRouteQuery({
key: 'settings', // Optional for object schemas - adds root prefix to all properties
schema: {
theme: z.string(),
notifications: z.boolean()
},
default: {
theme: 'light',
notifications: true
}
})
// URL: ?settings.theme=dark&settings.notifications=false
// Without key: ?theme=dark¬ifications=false
const sort = useRouteQuery({
schema: {
key: z.string(),
dir: z.enum(['asc', 'desc'])
},
default: { key: 'id', dir: 'asc' },
nullable: true // Allows the entire object to be null
})
// Type: Ref<{ key: string; dir: 'asc' | 'desc' } | null>
The main composable for managing URL query parameters.
Parameter | Type | Required | Description |
---|---|---|---|
schema |
z.ZodType | Record<string, z.ZodType> |
Yes | Zod schema for validation |
default |
NonNullable<Output> |
Yes | Default value (won't appear in URL when active) |
key |
string |
Required for single values | Root key for single value schemas or optional prefix for object schemas |
nullable |
boolean |
No | Whether the entire value can be null |
enabled |
boolean |
No | Enable/disable URL synchronization |
debug |
boolean |
No | Enable debug logging |
mode |
'push' | 'replace' |
No | Navigation mode (default: 'replace') |
Ref<Output>
- A reactive reference to the synchronized state
-
Default Values: Default values are never shown in the URL. A parameter only appears in the URL when its value differs from the default.
-
Root Keys for Object Schemas: When using a
key
with object schemas, it acts as a prefix for all properties:// With key useRouteQuery({ key: 'user', schema: { name: z.string() }, default: { name: '' } }) // URL: ?user.name=John // Without key useRouteQuery({ schema: { name: z.string() }, default: { name: '' } }) // URL: ?name=John
-
Nested Objects: Deep object structures are automatically flattened using dot notation:
// State { filters: { date: { from: '2024-01-01' } } } // URL ?filters.date.from=2024-01-01
-
Arrays: Arrays are JSON stringified in the URL:
// State { tags: ['vue', 'typescript'] } // URL ?tags=["vue","typescript"]
-
Multiple Instances: Multiple
useRouteQuery
instances with the same key will stay synchronized. However, ensure they use compatible schemas to avoid conflicts. -
Schema Validation: Don't use Zod's
.default()
function - use thedefault
parameter instead. -
Navigation Mode: The
mode
parameter controls how router navigation occurs:'replace'
(default): Updates the URL without creating a new history entry'push'
: Creates a new history entry for each update
When multiple instances update simultaneously, if any instance uses
'push'
, the router will use push mode for that batch of updates.
// Using push mode for filters to enable browser back/forward navigation
const filters = useRouteQuery({
schema: {
category: z.string(),
priceRange: z.object({
min: z.number(),
max: z.number()
})
},
default: {
category: '',
priceRange: { min: 0, max: 1000 }
},
mode: 'push' // Each filter change creates a history entry
})
// Using replace mode for preferences (default)
const preferences = useRouteQuery({
schema: {
view: z.enum(['list', 'grid']),
density: z.enum(['compact', 'comfortable'])
},
default: {
view: 'list',
density: 'comfortable'
}
// mode: 'replace' is the default
})
// If both update simultaneously and filters uses 'push',
// the router will use push for that update
const accountSettings = useRouteQuery({
key: 'account', // All properties will be prefixed with 'account.'
schema: {
profile: z.object({
name: z.string(),
email: z.string()
}),
preferences: z.object({
theme: z.enum(['light', 'dark']),
notifications: z.boolean()
})
},
default: {
profile: { name: '', email: '' },
preferences: { theme: 'light', notifications: true }
}
})
// URL structure:
// ?account.profile.name=John&account.profile.email=john@example.com&account.preferences.theme=dark
// Without the key, it would be:
// ?profile.name=John&profile.email=john@example.com&preferences.theme=dark
const filters = useRouteQuery({
schema: {
searchQuery: z.string().optional(),
filters: z.object({
statuses: z.array(z.string()),
categories: z.array(z.string()),
authorizationLabels: z.boolean(),
startDate: z.object({
from: z.string(),
to: z.string()
})
}),
quickFilters: z.record(z.string(), z.any())
},
default: {
searchQuery: '',
filters: {
statuses: [],
categories: [],
authorizationLabels: false,
startDate: { from: '', to: '' }
},
quickFilters: {}
}
})
// URL when changed from default:
// ?searchQuery=test&filters.statuses=["TO_CHECK_EP","VALIDATED"]&filters.categories=["19KZisAzakz3WESKnUy_C"]&filters.authorizationLabels=true&filters.startDate.from=2025-04-23&filters.startDate.to=2025-04-24
const sort = useRouteQuery({
schema: {
key: z.string(),
dir: z.enum(['asc', 'desc'])
},
default: { key: 'createdAt', dir: 'desc' },
nullable: true
})
// Can be set to null to disable sorting
sort.value = null
// URL when null: parameters removed
// URL when default: parameters removed
// URL when custom: ?key=name&dir=asc
const userPreferences = useRouteQuery({
schema: {
theme: z.enum(['light', 'dark', 'system']),
density: z.enum(['compact', 'comfortable', 'spacious']),
notifications: z.object({
email: z.boolean(),
push: z.boolean(),
frequency: z.enum(['instant', 'daily', 'weekly'])
})
},
default: {
theme: 'system',
density: 'comfortable',
notifications: {
email: true,
push: false,
frequency: 'daily'
}
},
enabled: shouldPersistPreferences.value // Conditionally enable URL sync
})
const pagination = useRouteQuery({
schema: {
pageSize: z.number(),
pageIndex: z.number()
},
default: {
pageSize: 20,
pageIndex: 1
},
mode: 'push' // Enable history for pagination
})
// Only appears in URL when different from default
// ?pageSize=50&pageIndex=3
- Initialization: The composable initializes with either URL values (if present) or default values
- Synchronization: Changes to the ref automatically update the URL, and URL changes update the ref
- Cleanup: When values match defaults, they're removed from the URL
- Batching: Multiple rapid updates are batched and processed in the next tick
-
Objects: Nested objects use dot notation
{ user: { settings: { theme: 'dark' } } } // Becomes: ?user.settings.theme=dark
-
Arrays: Arrays are JSON stringified
{ tags: ['vue', 'ts'] } // Becomes: ?tags=["vue","ts"]
-
Booleans: Represented as string values
{ active: true } // Becomes: ?active=true
-
Numbers: Preserved as numeric strings
{ count: 42 } // Becomes: ?count=42
-
Null/Undefined: Removed from URL entirely
The library uses a singleton GlobalQueryManager
that:
- Batches multiple updates to prevent race conditions
- Processes all updates in the next tick
- Ensures consistent state across all instances
- Handles cleanup of removed properties
- Intelligently combines navigation modes (push if any instance requests push)
- Batching: All updates are batched to minimize router operations
- Shallow Comparison: Uses shallow comparison for primitives
- Deep Comparison: Uses recursive comparison for objects only when needed
- URL Size: Be mindful of browser URL length limits with large data structures
Works in all modern browsers that support:
- Vue 3
- URLSearchParams API
- ES2015+
The library is written in TypeScript and provides full type inference:
// Inferred type based on schema
const data = useRouteQuery({
schema: {
name: z.string(),
age: z.number().optional()
},
default: { name: '', age: undefined }
})
// data is Ref<{ name: string; age?: number }>
const filters = useRouteQuery({...})
// Reset to default (removes from URL)
filters.value = { ...defaultFilters }
const config = useRouteQuery({
schema: {
advanced: z.boolean(),
// Only used when advanced is true
customSettings: z.object({...}).optional()
},
default: {
advanced: false,
customSettings: undefined
}
})
// Both instances stay in sync
const userSettings1 = useRouteQuery({
key: 'settings',
schema: z.object({...}),
default: {...}
})
const userSettings2 = useRouteQuery({
key: 'settings', // Same key
schema: z.object({...}), // Must be compatible
default: {...}
})
- Schema Mismatch: Ensure multiple instances with the same key use compatible schemas
- Default Values: Remember that default values never appear in the URL
- Type Errors: Use proper TypeScript types when working with refs
- Performance: For large data structures, consider pagination or filtering
- Navigation Conflicts: When using mixed modes, push takes precedence over replace
Enable debug mode to see internal operations:
const data = useRouteQuery({
// ... other options
debug: true
})
MIT
Contributions are welcome! Please read our contributing guidelines before submitting a PR.