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