Back to Blog

React Query Infinite Scroll in 2026: useInfiniteQuery, Cursor Pagination, and Intersection Observer

Build infinite scroll with React Query useInfiniteQuery: cursor-based pagination, Intersection Observer trigger, bi-directional infinite scroll, and virtualized infinite lists.

Viprasol Tech Team
February 21, 2027
13 min read

React Query Infinite Scroll in 2026: useInfiniteQuery, Cursor Pagination, and Intersection Observer

Infinite scroll has replaced pagination on most content feeds. Done right โ€” cursor-based, not offset-based โ€” it's fast at any depth and handles concurrent inserts cleanly. Done wrong, it shows duplicate items when new content is inserted and gets progressively slower as users scroll deeper.

This post covers useInfiniteQuery with cursor pagination (no offset), an Intersection Observer trigger, bi-directional loading (load newer content above), and combining with react-virtual for lists with thousands of items.


API: Cursor-Based Pagination

// app/api/feed/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getWorkspaceContext } from "@/lib/auth/workspace-context";
import { db } from "@/lib/db";
import { z } from "zod";

const QuerySchema = z.object({
  cursor: z.string().optional(),  // Last seen item ID
  limit:  z.coerce.number().min(1).max(50).default(20),
  filter: z.enum(["all", "mine", "mentions"]).default("all"),
});

export async function GET(req: NextRequest) {
  const ctx = await getWorkspaceContext();
  if (!ctx) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const params = QuerySchema.safeParse(
    Object.fromEntries(req.nextUrl.searchParams)
  );
  if (!params.success) return NextResponse.json({ error: "Invalid" }, { status: 400 });

  const { cursor, limit, filter } = params.data;

  const items = await db.feedItem.findMany({
    where: {
      workspaceId: ctx.workspaceId,
      // Cursor: only items older than the cursor
      ...(cursor && {
        id: { lt: cursor }, // ULID/UUID v7: lexicographic = chronological
      }),
      ...(filter === "mine" && { actorId: ctx.userId }),
    },
    orderBy: { id: "desc" },   // Newest first
    take: limit + 1,            // Fetch one extra to check hasNextPage
    include: {
      actor: { select: { name: true, avatarUrl: true } },
    },
  });

  const hasNextPage = items.length > limit;
  const data = hasNextPage ? items.slice(0, limit) : items;
  const nextCursor = hasNextPage ? data[data.length - 1].id : undefined;

  return NextResponse.json({ items: data, nextCursor, hasNextPage });
}

useInfiniteQuery Setup

// hooks/useFeed.ts
import {
  useInfiniteQuery,
  useSuspenseInfiniteQuery,
  type InfiniteData,
} from "@tanstack/react-query";

interface FeedItem {
  id: string;
  content: string;
  actorId: string;
  actor: { name: string; avatarUrl: string | null };
  createdAt: string;
}

interface FeedPage {
  items: FeedItem[];
  nextCursor: string | undefined;
  hasNextPage: boolean;
}

async function fetchFeedPage({
  pageParam,
  filter,
}: {
  pageParam: string | undefined;
  filter: string;
}): Promise<FeedPage> {
  const url = new URL("/api/feed", window.location.origin);
  url.searchParams.set("limit", "20");
  url.searchParams.set("filter", filter);
  if (pageParam) url.searchParams.set("cursor", pageParam);

  const res = await fetch(url.toString());
  if (!res.ok) throw new Error("Failed to load feed");
  return res.json();
}

export function useFeed(filter: string = "all") {
  return useInfiniteQuery({
    queryKey: ["feed", filter],

    queryFn: ({ pageParam }) => fetchFeedPage({ pageParam, filter }),

    // First page has no cursor
    initialPageParam: undefined as string | undefined,

    // Extract cursor for next page from last page's response
    getNextPageParam: (lastPage) => lastPage.nextCursor,

    // Keep previous pages while fetching next (avoids list collapse)
    placeholderData: (prev) => prev,

    staleTime: 30 * 1000,  // Consider fresh for 30 seconds
  });
}

// Flattened list of all loaded items across pages
export function useFeedItems(filter?: string) {
  const query = useFeed(filter);
  const items = query.data?.pages.flatMap((page) => page.items) ?? [];
  return { ...query, items };
}

๐ŸŒ Looking for a Dev Team That Actually Delivers?

Most agencies sell you a project manager and assign juniors. Viprasol is different โ€” senior engineers only, direct Slack access, and a 5.0โ˜… Upwork record across 100+ projects.

  • React, Next.js, Node.js, TypeScript โ€” production-grade stack
  • Fixed-price contracts โ€” no surprise invoices
  • Full source code ownership from day one
  • 90-day post-launch support included

Intersection Observer Trigger

// hooks/useIntersectionObserver.ts
import { useEffect, useRef, useState } from "react";

export function useIntersectionObserver(options?: IntersectionObserverInit) {
  const ref = useRef<HTMLDivElement>(null);
  const [isIntersecting, setIsIntersecting] = useState(false);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const observer = new IntersectionObserver(([entry]) => {
      setIsIntersecting(entry.isIntersecting);
    }, {
      threshold: 0.1,      // Trigger when 10% visible
      rootMargin: "200px", // Start loading 200px before reaching bottom
      ...options,
    });

    observer.observe(element);
    return () => observer.disconnect();
  }, []);

  return { ref, isIntersecting };
}

Complete Feed Component

// components/Feed/ActivityFeed.tsx
"use client";

import { useEffect } from "react";
import { useFeedItems } from "@/hooks/useFeed";
import { useIntersectionObserver } from "@/hooks/useIntersectionObserver";
import { FeedItem } from "./FeedItem";
import { Loader2 } from "lucide-react";

export function ActivityFeed({ filter = "all" }: { filter?: string }) {
  const {
    items,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    isError,
    error,
  } = useFeedItems(filter);

  // Sentinel element at the bottom of the list
  const { ref: sentinelRef, isIntersecting } = useIntersectionObserver({
    rootMargin: "200px",
  });

  // Load next page when sentinel is visible
  useEffect(() => {
    if (isIntersecting && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [isIntersecting, hasNextPage, isFetchingNextPage, fetchNextPage]);

  if (isLoading) {
    return (
      <div className="flex justify-center py-12">
        <Loader2 className="h-6 w-6 animate-spin text-gray-400" />
      </div>
    );
  }

  if (isError) {
    return (
      <div className="text-center py-8 text-red-500 text-sm">
        Failed to load feed: {error instanceof Error ? error.message : "Unknown error"}
      </div>
    );
  }

  if (items.length === 0) {
    return (
      <div className="text-center py-12 text-gray-400 text-sm">
        No activity yet.
      </div>
    );
  }

  return (
    <div className="space-y-0 divide-y divide-gray-100">
      {items.map((item) => (
        <FeedItem key={item.id} item={item} />
      ))}

      {/* Sentinel: triggers next page load when visible */}
      <div ref={sentinelRef} className="py-4 flex justify-center">
        {isFetchingNextPage && (
          <Loader2 className="h-5 w-5 animate-spin text-gray-300" />
        )}
        {!hasNextPage && items.length > 0 && (
          <p className="text-xs text-gray-300">You've reached the end</p>
        )}
      </div>
    </div>
  );
}

๐Ÿš€ Senior Engineers. No Junior Handoffs. Ever.

You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.

  • MVPs in 4โ€“8 weeks, full platforms in 3โ€“5 months
  • Lighthouse 90+ performance scores standard
  • Works across US, UK, AU timezones
  • Free 30-min architecture review, no commitment

Bi-Directional: Loading Newer Items Above

For chat or live feeds where new items appear at the top:

// hooks/useBidirectionalFeed.ts
import { useInfiniteQuery } from "@tanstack/react-query";
import { useEffect, useRef } from "react";

export function useBidirectionalFeed(channelId: string) {
  const scrollAnchorRef = useRef<HTMLDivElement>(null);

  const query = useInfiniteQuery({
    queryKey: ["messages", channelId],
    queryFn: ({ pageParam }) =>
      fetchMessages({ channelId, cursor: pageParam?.cursor, direction: pageParam?.direction ?? "older" }),
    initialPageParam: undefined as { cursor?: string; direction: "older" | "newer" } | undefined,
    getNextPageParam: (lastPage) =>
      lastPage.hasOlderMessages ? { cursor: lastPage.oldestCursor, direction: "older" } : undefined,
    getPreviousPageParam: (firstPage) =>
      firstPage.hasNewerMessages ? { cursor: firstPage.newestCursor, direction: "newer" } : undefined,
  });

  // Maintain scroll position when prepending older messages
  useEffect(() => {
    if (!scrollAnchorRef.current) return;
    scrollAnchorRef.current.scrollIntoView({ block: "start" });
  }, [query.data?.pages.length]);

  const messages = query.data?.pages.flatMap((p) => p.messages) ?? [];

  return { ...query, messages, scrollAnchorRef };
}

Prefetching Next Page

// Prefetch next page before user reaches the sentinel (smoother UX)
import { useQueryClient } from "@tanstack/react-query";

function usePrefetchNextPage(filter: string) {
  const queryClient = useQueryClient();
  const { data } = useFeed(filter);

  const prefetch = async () => {
    const lastPage = data?.pages[data.pages.length - 1];
    if (!lastPage?.nextCursor) return;

    await queryClient.prefetchInfiniteQuery({
      queryKey: ["feed", filter],
      queryFn: ({ pageParam }) => fetchFeedPage({ pageParam, filter }),
      initialPageParam: lastPage.nextCursor,
      pages: 1,  // Only prefetch one page ahead
    });
  };

  return prefetch;
}

Combining with Virtualization

For lists with 10,000+ items that need to stay in DOM:

// components/Feed/VirtualizedFeed.tsx
"use client";

import { useEffect, useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useFeedItems } from "@/hooks/useFeed";

export function VirtualizedFeed() {
  const { items, fetchNextPage, hasNextPage, isFetchingNextPage } = useFeedItems();
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: hasNextPage ? items.length + 1 : items.length, // +1 for loader row
    getScrollElement: () => parentRef.current,
    estimateSize: () => 80,    // Estimated row height in px
    overscan: 5,               // Render 5 extra rows outside viewport
  });

  // Load more when last virtual row is rendered
  useEffect(() => {
    const [lastItem] = [...virtualizer.getVirtualItems()].reverse();
    if (!lastItem) return;

    if (
      lastItem.index >= items.length - 1 &&
      hasNextPage &&
      !isFetchingNextPage
    ) {
      fetchNextPage();
    }
  }, [
    virtualizer.getVirtualItems(),
    items.length,
    hasNextPage,
    isFetchingNextPage,
  ]);

  return (
    <div ref={parentRef} className="h-screen overflow-auto">
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: "relative",
        }}
      >
        {virtualizer.getVirtualItems().map((virtualRow) => {
          const isLoaderRow = virtualRow.index >= items.length;
          const item = items[virtualRow.index];

          return (
            <div
              key={virtualRow.key}
              data-index={virtualRow.index}
              ref={virtualizer.measureElement}
              style={{
                position: "absolute",
                top: 0,
                left: 0,
                width: "100%",
                transform: `translateY(${virtualRow.start}px)`,
              }}
            >
              {isLoaderRow ? (
                <div className="flex justify-center py-4">
                  {isFetchingNextPage ? (
                    <span className="text-sm text-gray-400">Loading more...</span>
                  ) : (
                    <span className="text-sm text-gray-300">End of feed</span>
                  )}
                </div>
              ) : (
                <FeedItem item={item} />
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}

Common Mistakes

// โŒ Offset pagination breaks with concurrent inserts
// Page 1: items 1-20
// User inserts item 0.5
// Page 2: OFFSET 20 โ†’ item 20 is now item 21 โ†’ item 20 duplicates!
fetch(`/api/feed?page=2&limit=20`);

// โœ… Cursor pagination: always consistent
// cursor = last seen ID โ†’ "give me items older than X"
// New inserts don't affect existing cursors
fetch(`/api/feed?cursor=${lastItemId}&limit=20`);

// โŒ Resetting query on filter change loses scroll position
const { data } = useInfiniteQuery({
  queryKey: ["feed"],  // Same key regardless of filter
  // ...
});

// โœ… Include filter in queryKey โ€” each filter has its own cache
const { data } = useInfiniteQuery({
  queryKey: ["feed", filter],
  // ...
});

// โŒ Fetching next page without checking hasNextPage
useEffect(() => {
  if (isIntersecting) fetchNextPage(); // Keeps firing even at end
}, [isIntersecting]);

// โœ… Guard against fetching past the end
useEffect(() => {
  if (isIntersecting && hasNextPage && !isFetchingNextPage) {
    fetchNextPage();
  }
}, [isIntersecting, hasNextPage, isFetchingNextPage]);

Cost and Timeline

ComponentTimelineCost (USD)
Cursor pagination API endpoint0.5โ€“1 day$400โ€“$800
useInfiniteQuery + Intersection Observer0.5โ€“1 day$400โ€“$800
Bi-directional (chat-style)1โ€“2 days$800โ€“$1,600
Virtualized infinite list1โ€“2 days$800โ€“$1,600
Full infinite scroll system1โ€“2 weeks$5,000โ€“$8,000

See Also


Working With Viprasol

We build infinite scroll feeds for SaaS products โ€” activity feeds, message lists, content libraries, and search results. Our team has shipped infinite scroll implementations handling tens of thousands of items with consistent performance.

What we deliver:

  • Cursor-based pagination API (no offset, handles concurrent inserts)
  • useInfiniteQuery with Intersection Observer sentinel
  • Bi-directional loading for chat/real-time feeds
  • Virtualized infinite list with @tanstack/react-virtual
  • Prefetch strategy for seamless user experience

Explore our web development services or contact us to build your infinite scroll feature.

Share this article:

About the Author

V

Viprasol Tech Team

Custom Software Development Specialists

The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.

MT4/MT5 EA DevelopmentAI Agent SystemsSaaS DevelopmentAlgorithmic Trading

Need a Modern Web Application?

From landing pages to complex SaaS platforms โ€” we build it all with Next.js and React.

Free consultation โ€ข No commitment โ€ข Response within 24 hours

Viprasol ยท Web Development

Need a custom web application built?

We build React and Next.js web applications with Lighthouse โ‰ฅ90 scores, mobile-first design, and full source code ownership. Senior engineers only โ€” from architecture through deployment.