npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@jimjam.dev/url-state

v1.2.0

Published

A url state manager for nextjs

Downloads

19

Readme

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

# 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:

// 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:

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

'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

'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

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

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

// 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

// 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

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

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

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