@jimjam.dev/url-state
Version:
A url state manager for nextjs
451 lines (387 loc) • 13.2 kB
Markdown
# URL State Management for Next.js
A powerful, type-safe URL state manager for Next.js applications that synchronizes component state with URL search parameters.
## Installation
```bash
# npm
npm install @jimjam.dev/url-state
# yarn
yarn add @jimjam.dev/url-state
# pnpm
pnpm add @jimjam.dev/url-state
```
## Features
- 🔄 **Automatic URL synchronization** - Component state stays in sync with URL
- 🎯 **Type-safe** - Full TypeScript support with generics
- ⚡ **Performance optimized** - Built-in caching and batching
- 🔧 **Multiple instances** - Use unique keys for multiple tables/components
- 🎛️ **Server-side compatible** - Works with Next.js App Router SSR
- 🛠️ **Flexible** - Simple hooks or advanced QueryBuilder patterns
## Quick Start
### Create your qb.ts file:
```tsx
// lib/utils/qb.ts
import { createQueryBuilder } from '@jimjam.dev/url-state';
export const qb = createQueryBuilder({
defaults: {
page: 1,
pageSize: 10
},
ignored: ['debug', 'csrf'],
mappings: {
orderBy: (value) => value,
orderDir: (value) => value || '+'
},
postProcess: (result) => {
// Add any custom logic like sorting
if (result.orderBy) {
result.sort = `${result.orderDir}${result.orderBy}`;
}
return result;
}
});
```
### Use it in your components:
```tsx
import { qb } from '@/lib/utils/qb';
export default async function UsersPage({ searchParams }) {
const query = qb(searchParams, 'users_');
const users = await getUsers(query);
return <UsersTable data={users} />;
}
```
## Examples
## Client-Side Examples
### Basic URL State Hook
```tsx
'use client';
import { useUrlState } from '@jimjam.dev/url-state';
function UserTableControls() {
const { state, setItem, setItems, deleteItem, deleteAllItems } = useUrlState('users_');
return (
<div className="flex gap-4 p-4">
{/* Search Input */}
<input
type="text"
placeholder="Search users..."
value={(state.search as string) || ''}
onChange={(e) => setItem('search', e.target.value)}
/>
{/* Status Filter */}
<select
value={(state.status as string) || ''}
onChange={(e) => setItem('status', e.target.value || undefined)}
>
<option value="">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
{/* Page Size */}
<select
value={(state.pageSize as number) || 10}
onChange={(e) => setItem('pageSize', parseInt(e.target.value))}
>
<option value="10">10 per page</option>
<option value="25">25 per page</option>
<option value="50">50 per page</option>
</select>
{/* Pagination */}
<button
onClick={() => setItem('page', ((state.page as number) || 1) - 1)}
disabled={((state.page as number) || 1) <= 1}
>
Previous
</button>
<span>Page {(state.page as number) || 1}</span>
<button onClick={() => setItem('page', ((state.page as number) || 1) + 1)}>
Next
</button>
{/* Clear All */}
<button onClick={deleteAllItems}>Clear All Filters</button>
</div>
);
}
```
### Multiple Tables on Same Page
```tsx
'use client';
import { useUrlState } from '@jimjam.dev/url-state';
function DualTablePage() {
// Users table state (u_ prefix)
const usersState = useUrlState('u_');
// Issues table state (i_ prefix)
const issuesState = useUrlState('i_');
return (
<div>
{/* Users Controls */}
<div>
<input
placeholder="Search users..."
value={(usersState.state.search as string) || ''}
onChange={(e) => usersState.setItem('search', e.target.value)}
/>
<button onClick={() => usersState.setItem('page', 1)}>Reset Page</button>
</div>
{/* Issues Controls */}
<div>
<input
placeholder="Search issues..."
value={(issuesState.state.search as string) || ''}
onChange={(e) => issuesState.setItem('search', e.target.value)}
/>
<select
value={(issuesState.state.priority as string) || ''}
onChange={(e) => issuesState.setItem('priority', e.target.value)}
>
<option value="">All Priorities</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
</div>
);
}
// URL will look like: ?u_search=john&u_page=2&i_search=bug&i_priority=high
```
## Server-Side Examples
### Simple Server Component Usage
```tsx
import { ReadonlyURLSearchParams } from 'next/navigation';
import { getQueryFromUrl } from '@jimjam.dev/url-state';
// Define your query type
interface UserQuery {
page: number;
pageSize: number;
search?: string;
status?: 'active' | 'inactive';
role?: string;
}
interface UsersPageProps {
searchParams: ReadonlyURLSearchParams;
}
export default function UsersPage({ searchParams }: UsersPageProps) {
return (
<div>
<h1>Users Management</h1>
<Suspense fallback={<div>Loading...</div>}>
<UsersTable searchParams={searchParams} />
</Suspense>
</div>
);
}
async function UsersTable({ searchParams }: UsersPageProps) {
// Extract type-safe query from URL
const query = getQueryFromUrl<UserQuery>(searchParams, 'users_');
// Use with your backend (Prisma example)
const users = await getUsers(query);
return (
<div>
<div>Page {query.page} • {users.length} results</div>
{query.search && <div>Searching for: "{query.search}"</div>}
{query.status && <div>Status: {query.status}</div>}
<div className="grid gap-4">
{users.map(user => (
<div key={user.id}>{user.name}</div>
))}
</div>
</div>
);
}
```
### Advanced Server Component with Custom QueryBuilder
```tsx
import { ReadonlyURLSearchParams } from 'next/navigation';
import { QueryBuilder } from '@jimjam.dev/url-state';
interface AdvancedUserQuery {
page: number;
pageSize: number;
search?: string;
roles?: string[];
createdAfter?: Date;
tags?: string[];
includeInactive?: boolean;
}
async function AdvancedUsersTable({ searchParams }: { searchParams: ReadonlyURLSearchParams }) {
// Create custom QueryBuilder with app-specific logic
const query = new QueryBuilder<AdvancedUserQuery>()
.setDefaults({
page: 1,
pageSize: 25,
includeInactive: false,
roles: []
})
.ignore('debug', 'internal_token', 'csrf') // Skip these URL params
.addMapping('roles', (value) => {
// Ensure roles is always an array
return Array.isArray(value) ? value : [value];
})
.addMapping('createdAfter', (value) => {
// Convert string to Date
return typeof value === 'string' ? new Date(value) : value;
})
.addMapping('tags', (value) => {
// Split comma-separated tags
return typeof value === 'string' ? value.split(',').map(t => t.trim()) : value;
})
.addMapping('includeInactive', (value) => {
// Convert string to boolean
return value === 'true' || value === true;
})
.fromUrl(searchParams, 'users_');
// Step 3: Use processed query with backend
const users = await getAdvancedUsers(query);
return (
<div>
<div>
Page {query.page} of {Math.ceil((users.total || 0) / query.pageSize)}
{query.search && ` • Search: "${query.search}"`}
{query.roles.length > 0 && ` • Roles: ${query.roles.join(', ')}`}
{query.createdAfter && ` • Created after: ${query.createdAfter.toLocaleDateString()}`}
</div>
<div className="grid gap-4">
{users.data.map(user => (
<div key={user.id}>
<h3>{user.name}</h3>
<p>Roles: {user.roles.join(', ')}</p>
<p>Created: {new Date(user.createdAt).toLocaleDateString()}</p>
</div>
))}
</div>
</div>
);
}
```
## Custom QueryBuilder Initialization
### App-Wide QueryBuilder for Consistent Behavior
```tsx
// lib/query-builders.ts
import { createQueryBuilder } from '@jimjam.dev/url-state';
// Create reusable query functions for your app
export const usersQuery = createQueryBuilder()
.setDefaults({ page: 1, pageSize: 10, status: 'active' })
.ignore('debug', 'session_id', 'csrf_token')
.addMapping('roles', (value) => Array.isArray(value) ? value : [value])
.addMapping('createdAfter', (value) => new Date(value))
.addMapping('isActive', (value) => value === 'true' || value === true)
.toFunction();
export const issuesQuery = createQueryBuilder()
.setDefaults({ page: 1, pageSize: 20, status: 'open', priority: 'medium' })
.ignore('debug')
.addMapping('tags', (value) => typeof value === 'string' ? value.split(',') : value)
.addMapping('assignees', (value) => Array.isArray(value) ? value : [value])
.addMapping('dueDate', (value) => value ? new Date(value) : undefined)
.toFunction();
export const paymentsQuery = createQueryBuilder()
.setDefaults({ page: 1, pageSize: 50, status: 'pending' })
.addMapping('amount', (value) => parseFloat(value))
.addMapping('dueAfter', (value) => new Date(value))
.addMapping('includeOverdue', (value) => value === 'true')
.toFunction();
// Usage in components:
// const users = await getUsers(usersQuery(searchParams, 'u_'));
// const issues = await getIssues(issuesQuery(searchParams, 'i_'));
```
### Dynamic QueryBuilder Based on User Preferences
```tsx
// lib/dynamic-query-builder.ts
import { createQueryBuilder } from '@jimjam.dev/url-state';
export function createUserPreferenceQuery(userPrefs: UserPreferences) {
return createQueryBuilder()
.setDefaults({
page: 1,
pageSize: userPrefs.defaultPageSize || 10,
sortBy: userPrefs.defaultSort || 'createdAt',
sortOrder: userPrefs.defaultSortDir || 'desc'
})
.ignore(...(userPrefs.ignoredParams || ['debug']))
.addMapping('dateRange', (value) => {
// Custom date range parsing based on user's date format preference
if (typeof value === 'string' && value.includes(',')) {
const [start, end] = value.split(',');
return {
start: parseUserDate(start, userPrefs.dateFormat),
end: parseUserDate(end, userPrefs.dateFormat)
};
}
return value;
})
.toFunction();
}
// Usage:
// const userQuery = createUserPreferenceQuery(currentUser.preferences);
// const query = userQuery(searchParams, 'data_');
```
## Backend Integration Examples
### Prisma Integration
```tsx
async function getUsers(query: UserQuery) {
return await prisma.user.findMany({
where: {
...(query.search && {
OR: [
{ firstName: { contains: query.search, mode: 'insensitive' } },
{ lastName: { contains: query.search, mode: 'insensitive' } },
{ email: { contains: query.search, mode: 'insensitive' } }
]
}),
...(query.status && { status: query.status }),
...(query.role && { role: query.role })
},
orderBy: query.sortBy ? { [query.sortBy]: query.sortOrder === '+' ? 'asc' : 'desc' } : { createdAt: 'desc' },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
include: {
profile: true,
roles: true
}
});
}
```
### .NET API Integration
```tsx
async function getUsers(query: UserQuery) {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(query) // Send exact query object
});
if (!response.ok) throw new Error('Failed to fetch users');
return response.json();
}
```
### REST API Integration
```tsx
import qs from 'qs';
async function getUsers(query: UserQuery) {
// Much simpler with qs library
const queryString = qs.stringify(query, {
skipNulls: true,
arrayFormat: 'repeat' // ?tags=tag1&tags=tag2 format
});
const response = await fetch(`/api/users?${queryString}`);
return response.json();
}
// Alternative without qs (if you prefer no dependencies):
async function getUsersVanilla(query: UserQuery) {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (Array.isArray(value)) {
value.forEach(v => params.append(key, String(v)));
} else {
params.append(key, String(value));
}
}
});
const response = await fetch(`/api/users?${params}`);
return response.json();
}
```
## Key Benefits
- **Type Safety**: Generic functions provide compile-time type checking
- **Backend Agnostic**: Works with Prisma, .NET, REST, GraphQL, etc.
- **Multiple Instances**: Use unique keys to avoid conflicts on same page
- **Custom Processing**: QueryBuilder for app-specific defaults and mappings
- **SEO Friendly**: All state in URL for bookmarking and crawling
- **Performance**: Server-side rendering with URL state
- **Flexible**: Simple direct usage or advanced custom processing