nextjs with nuqs

In modern web development, the gulf between a sluggish application and a delightful one often comes down to how intelligently data is loaded and presented. When users encounter product catalogs with thousands of items, activity feeds spanning months, or analytics dashboards tracking millions of events, the traditional approach of loading everything upfront becomes untenable. Pages slow to a crawl, browsers struggle with memory, and users abandon the experience before it even begins.

The solution lies in server-side pagination* (dividing large datasets into smaller chunks at the database level), combined with **infinite scroll**, a UI pattern where new content loads automatically as the user approaches the bottom of the viewport, eliminating the friction of manual “Next” buttons and creating a continuous, mobile-friendly browsing experience. **Next.js** provides the ideal foundation for this architecture through its App Router and Server Components, allowing us to fetch the initial page of data server-side for instant first paint, then progressively load subsequent pages client-side as the user scrolls. However, the critical challenge emerges when we need this scroll position to survive page refreshes and be shareable via URL: manually managing URL parameters with useRouter and URLSearchParams fails because URL changes don’t trigger component re-renders, creating a fragile synchronization gap between UI state and browser state. This is where *nuqs (Next.js URL Query State) becomes essential: it treats URL search parameters as reactive, type-safe React state, automatically updating the URL via shallow routing while triggering re-renders, effectively bridging the browser’s stateless string-based URL with React’s internal component state. The result is an infinite scroll implementation where cursor positions are bookmarkable, shareable, SSR-safe, and refresh-resilient, all without manual plumbing.

Server-Side Fetching as Single Source of Truth

----------------------------------------------

A common pitfall in Next.js pagination implementations is the dual-source antipattern*: fetching the first page server-side for SEO and fast initial render, then switching to a client-side data fetching library like React Query for subsequent pages. While this approach seems pragmatic, leveraging Next.js’s SSR strengths while tapping into React Query’s caching and mutation features, it introduces significant architectural friction. You now have two separate data pipelines with different error handling, different loading states, and different cache invalidation strategies. More critically, the transition between server-fetched and client-fetched data creates a **hydration boundary** where the client must reconcile the server-rendered initial page with its own client-side cache, often leading to flickering content or duplicate requests on mount. Additionally, URL state managed by nuqs becomes ambiguous: does the cursor in the URL represent server-fetched data or client-fetched data? This split-brain problem compounds when implementing features like optimistic updates or real-time synchronization. By treating *Next.js Server Actions as the single source of truth (where both initial and subsequent pages flow through the same server-side data pipeline), you eliminate this complexity entirely. Server Actions allow client components to call server-side functions directly, maintaining a unified fetching strategy while still enabling progressive client-side loading. The URL cursor always points to a consistent server-side query, hydration mismatches disappear, and the mental model collapses from “SSR + client fetching” to simply “server fetching with progressive hydration.” This architectural clarity becomes especially valuable when debugging production issues or onboarding new developers who don’t need to learn two separate data fetching paradigms.

Implementation: Server-Driven Infinite Scroll with Unified State

----------------------------------------------------------------

The architecture materializes through three interconnected layers that maintain server-side fetching as the single source of truth while delivering a seamless infinite scroll experience.

The Server Component Foundation

export default async function UsersPage({ searchParams }: PageProps) {
  const { isActive, isPublic, query, page } = loadParams.parse(
    await searchParams
  );
  const initialData = await getUsers({
    isActive: isActive ?? undefined,
    isPublic: isPublic ?? undefined,
    query: query ?? undefined,
    page: page ?? undefined,
  });
  return (
    <UsersPageClient
      currentFilters={{ isActive, isPublic, query, page }}
      initialData={initialData}
    />
  );
}

The UsersPage server component acts as the data gateway. It extracts URL search parameters via loadParams.parse(), executes the getUsers() server-side query with those filters, and passes the resulting initialData to the client component. This ensures every render (whether initial page load or triggered by URL changes) originates from the same server-side data pipeline. The server component re-executes whenever the URL changes, fetching fresh data before hydration.

The Client Component: URL State Management

export const UsersPageClient: React.FC<UsersPageClientProps> = ({
  initialData,
  currentFilters,
}) => {
  const [refreshKey, setRefreshKey] = useState(0);
  const [{ isActive, isPublic, query, page }, setStates] = useQueryStates(
    parseParams,
    {
      shallow: false,
      throttleMs: 1000,
    }
  );
  const { items, ref, hasNextPage } = useInfiniteScroll({
    data: initialData,
    page: page ?? 1,
    setPage: (p) => setStates({ page: p }),
    refresh: refreshKey,
  });

UsersPageClient establishes the reactive URL binding through nuqs’s useQueryStates. The critical configuration here is shallow: false, which forces a full server-side refetch whenever URL parameters change rather than performing a shallow client-side navigation. This keeps the server component’s data fetching active for every page increment. The throttleMs: 1000 prevents excessive URL updates during rapid user interactions. When the infinite scroll hook calls setPage(p), it updates the URL via setStates({ page: p }), which triggers the server component to re-execute with the new page number, fetch fresh data, and pass it back down as initialData.

The Infinite Scroll Hook: Stateful Accumulation

export function useInfiniteScroll<T>({
  data,
  page,
  setPage,
  refresh = 0,
}: UseInfiniteScrollProps<T>) {
  const [allItems, setAllItems] = useState<T[]>(data.data);
  const processedPages = useRef<Set<number>>(new Set([data.page]));
  useEffect(() => {
    if (data.page === 1) {
      setAllItems(data.data);
      processedPages.current = new Set([1]);
    } else if (!processedPages.current.has(data.page)) {
      setAllItems((prev) => {
        const getIdentifier = (item: any) =>
          item.id || item._id || JSON.stringify(item);
        const itemsMap = new Map(prev.map((item) => [getIdentifier(item), item]));
        data.data.forEach((item) => itemsMap.set(getIdentifier(item), item));
        return Array.from(itemsMap.values());
      });
      processedPages.current.add(data.page);
    }
  }, [data.data, data.page, refresh]);

The hook’s primary responsibility is accumulation, not fetching. It maintains allItems as the running collection of all items seen across pages, using processedPages as a ref-based Set to track which page numbers have already been merged. When data.page === 1, it performs a full reset; this handles filter changes or manual navigation back to page 1. For subsequent pages, it employs a Map-based deduplication strategy: each item is keyed by item.id, item._id, or its JSON serialization, ensuring that if adjacent pages contain overlapping items (due to database mutations between requests), duplicates are automatically eliminated. The refresh dependency allows parent components to force a re-accumulation when filters change.

The Scroll Detection and Page Increment Logic

const { ref, inView } = useInView({
    threshold: 0,
    rootMargin: '100px',
  });
  const hasNextPage = page < data.totalPages;
  useEffect(() => {
    if (inView && hasNextPage && page === data.page) {
      setPage(page + 1);
    }
  }, [inView, hasNextPage, page, setPage, data.page]);
  return { items: allItems, ref, hasNextPage };
}

A sentinel element (referenced by ref) sits at the bottom of the rendered list, monitored by react-intersection-observer. When it enters the viewport with a 100px margin buffer, the effect checks three conditions: the sentinel is visible (inView), more pages exist (hasNextPage), and critically, page === data.page. This final guard prevents runaway requests during the async gap between calling setPage() and receiving the new initialData. Without this check, rapid scrolling could queue multiple page increments before the first fetch completes, causing cascading duplicate requests. Once all conditions pass, setPage(page + 1) fires, updating the URL via nuqs, triggering the server component to refetch, and restarting the cycle with fresh data flowing back down through initialData.

The Closed Loop

The elegance of this architecture lies in its unidirectional data flow: URL changes → server refetch → new initialData → hook accumulation → UI update. There are no client-side fetch calls, no cache invalidation logic, no hydration mismatches. The server remains the single source of truth, with the client-side hook acting purely as a stateful view layer that accumulates server responses into a continuous scroll experience.

Architectural Benefits: Type Safety, Declarative State, and Zero Drift

----------------------------------------------------------------------

This implementation pattern delivers several interconnected architectural guarantees that traditional pagination approaches struggle to achieve. The architecture enforces end-to-end type safety* through schema validation at both boundaries: the server component’s loadParams.parse(await searchParams) validates and types incoming URL parameters before they reach the data layer, while the client component’s useQueryStates(parseParams, …) ensures that URL state mutations are type-checked before updating the URL, with the generic useInfiniteScroll hook preserving type information from PaginatedApiResponse through to rendered components, eliminating the runtime type assertions that plague client-side fetching libraries. This type-safe foundation enables **declarative URL state without boilerplate**: whereas traditional Next.js URL management requires verbose imperative code: constructing URLSearchParams objects, calling router.push() with pathname reconstruction, manually parsing query strings, and setting up effects to watch useSearchParams(), the useQueryStates hook collapses this into an API mirroring useState where setStates({ page: p }) simultaneously updates local state, the browser URL, and triggers re-renders, with the parseParams schema serving as both runtime validator and type generator. This declarative approach seamlessly integrates **automatic shallow routing and debounced updates** via shallow: false, which forces full server refetches while avoiding full-page reloads through Next.js’s client-side navigation, combined with throttleMs: 1000 to debounce rapid state changes (such as typing in search fields), preventing browser history pollution and excessive server requests without explicit debounce hooks or cleanup logic. The entire pattern remains **SSR-friendly and Next.js App Router compatible** because the server component executes getUsers() on every render while the client hook merely accumulates resulting data, eliminating hydration mismatches: the server sees URL parameters via searchParams, fetches data, and renders the initial page, while the client receives this exact data as initialData without refetching, and when filters change or pagination occurs, the same server-side pipeline re-executes, maintaining consistency with streaming and Suspense boundaries. This architecture stays **tiny and dependency-free**, relying only on nuqs and react-intersection-observer: both lightweight, single-purpose libraries with no transitive dependencies, avoiding React Query bundles, Zustand stores, or custom cache invalidation logic, with the useInfiniteScroll hook itself being ~50 lines of straightforward React primitives that reduce bundle size and mental overhead. By routing all requests through getUsers(), the pattern **eliminates inconsistencies between client and server**: the dual-source problem where SSR and client fetching maintain separate truths disappears because both server and client always see identical data for identical URL parameters: if a user at /users?page=3&isActive=true refreshes, the server executes the exact same query the client would have triggered, guaranteeing identical results and preventing implementation drift. This unified data pipeline **reduces global state complexity** by eliminating the need for Redux, Zustand, or Jotai to persist accumulated items or coordinate pagination cursors: the URL itself becomes the global state, with the page parameter representing the pagination cursor visible to all components via useQueryStates, while useInfiniteScroll maintains only local accumulated items for its specific list instance, making state reasoning trivial: inspect the URL to understand exactly what data should be displayed. The culmination of these benefits is *shareable page state: because all pagination and filter state lives in the URL, every application state becomes inherently shareable: a user scrolled to page 5 of active, public users matching “marathon” can copy /users?page=5&isActive=true&isPublic=true&query=marathon and send it to a colleague who sees the exact same view, as the server component parses those parameters and the client hook accumulates them identically, eliminating “I’m seeing page 3 but you’re seeing page 1” confusion and making production debugging trivial since users can send exact URLs reproducing their view state without lengthy navigation instructions.

Conclusion: Simplicity Through Constraint

-----------------------------------------

The architecture presented here achieves something rare in modern web development: it makes a complex interaction pattern (infinite scroll with server-side pagination) simpler than its alternatives, not through abstraction layers or sophisticated caching strategies, but through architectural constraint. By refusing to split data fetching between server and client, by treating the URL as the definitive state container, and by reducing the infinite scroll hook to a pure accumulator rather than a data orchestrator, we eliminate entire categories of bugs that plague traditional implementations. There are no cache invalidation edge cases because there is no cache. There are no hydration mismatches because server and client execute the same query. There are no stale closure bugs because URL state lives outside React’s render cycle. There are no “works on initial load but breaks on navigation” issues because every navigation is an initial load from the server’s perspective.

This pattern excels in scenarios where data consistency and shareability matter more than millisecond-level client-side responsiveness: admin dashboards, content management systems, e-commerce catalogs, activity feeds, and any domain where users need to reference specific filtered views or debug production issues by sharing exact URLs. It trades the perceived convenience of client-side fetching libraries for the guarantee that your server’s database query is the single source of truth, visible and debuggable through the browser’s address bar. The cost is measured in additional server requests compared to aggressive client-side caching; the return is measured in eliminated race conditions, simplified mental models, and the confidence that refreshing the page will never break the user’s state.

As your application scales (adding real-time updates, optimistic mutations, or collaborative features), this foundation remains stable because the server component can evolve its data fetching logic (adding subscriptions, merging live updates, implementing cursor-based pagination) without touching the client hook’s accumulation strategy. The URL continues to represent the canonical filter state, the server continues to own data fetching, and the client continues to accumulate and render. The architecture scales through simplification, not sophistication. And when a user inevitably reports “the user list shows wrong data,” your first debugging step is trivial: ask them to send you the URL. Everything you need to reproduce their exact state is already there, encoded in twenty characters of query parameters, waiting to be parsed by the same loadParams.parse() function that serves production traffic. That’s the power of treating the URL not as a navigation artifact, but as your application’s source of truth.