Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Implement infinite scroll as a progressive enhancement over ordinary PHP pagination: render the first batch and a real, stable next-page link on the server, then let JavaScript fetch that link and append the next batch. If JavaScript fails, users and crawlers still have usable paginated URLs such as /articles?page=2.

Use server pagination as the foundation

An infinite feed is not a replacement for pagination. Your PHP route should produce a complete document for the first request and addressable subsequent pages. On the initial response, include the items and a crawlable link such as <a rel="next" href="/articles?page=2">Next</a>. JavaScript can consume that exact href, so the URL remains bookmarkable, testable without JavaScript, and available to search engines.

Choose a stable route

Use one route for both document and subsequent requests, for example /articles?page=2 for offset pagination or /articles?after=... for a cursor. Keep the item markup identical in every response; a fragment returned for page two must use the same card structure as page one. You can return an HTML fragment when PHP already owns rendering, or JSON such as {"items":[...],"next":"/articles?page=3"} when the browser owns templating.

Offset versus cursor pagination

Approach Best fit Advantages Risks and requirements
Page number (offset) Bounded or mostly stable lists Simple URLs, links, debugging, and fallback Deep offsets can become expensive; inserted or deleted rows can cause duplicates or skips unless ordering is deterministic
Cursor (seek) Large, changing feeds Consistent traversal and better performance at depth Requires an indexed, immutable ordering and a URL-safe, signed or server-verifiable cursor

For either model, order by a unique, deterministic tuple such as published_at DESC, id DESC. For a changing feed, encode the last ordering values in a cursor and query rows after that tuple. Include tenant, user, filters, and sort context in the cursor or verify them server-side so a cursor cannot be replayed against a different query.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Build a secure PHP endpoint

The example below uses PDO and returns either the complete page or an HTML fragment. It validates input, limits the batch size on the server, binds values, and escapes output.

Validate page and query state

<?php
$pdo = new PDO($dsn, $user, $password, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT, [
    'options' => ['default' => 1, 'min_range' => 1, 'max_range' => 100000]
]);
$limit = 20;                         // server-controlled page size
$offset = ($page - 1) * $limit;

$stmt = $pdo->prepare(
    'SELECT id, title, excerpt, published_at
     FROM articles
     WHERE status = :status
     ORDER BY published_at DESC, id DESC
     LIMIT :limit OFFSET :offset'
);
$stmt->bindValue(':status', 'published', PDO::PARAM_STR);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
$articles = $stmt->fetchAll();

function e(string $value): string {
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

function renderArticles(array $articles): string {
    ob_start();
    foreach ($articles as $article): ?>
      <article class="card" data-id="<?= (int)$article['id'] ?>">
        <h2><a href="/article.php?id=<?= (int)$article['id'] ?>"><?= e($article['title']) ?></a></h2>
        <p><?= e($article['excerpt']) ?></p>
        <time datetime="<?= e($article['published_at']) ?>"><?= e($article['published_at']) ?></time>
      </article>
    <?php endforeach;
    return ob_get_clean();
}

$next = count($articles) === $limit ? '/articles?page=' . ($page + 1) : null;
$isFragment = isset($_SERVER['HTTP_X_REQUESTED_WITH'])
    || isset($_GET['fragment']);

if ($isFragment) {
    header('Content-Type: text/html; charset=UTF-8');
    echo '<div class="items">' . renderArticles($articles) . '</div>';
    echo '<nav class="pagination">';
    if ($next) {
        echo '<a class="next" rel="next" href="' . e($next) . '">Next</a>';
    }
    echo '</nav>';
    exit;
}
?>
<main>
  <div id="feed" class="items"><?= renderArticles($articles) ?></div>
  <div id="status" aria-live="polite"></div>
  <button id="load-more" type="button">Load more</button>
  <nav id="pagination">
    <?php if ($next): ?>
      <a id="next" rel="next" href="<?= e($next) ?>">Next</a>
    <?php endif; ?>
  </nav>
</main>

Do not accept a client-supplied table name, SQL expression, arbitrary filter fragment, sort string, or unlimited limit. Map any permitted sort key to a fixed SQL expression. Apply authorization in the query itself, not only after rows have been fetched. OWASP’s rule is concise: “All data should be considered untrusted unless explicitly validated and safely handled.”

Cursor query for a changing feed

With a cursor containing the last row’s timestamp and ID, use a seek condition rather than a growing offset:

SELECT id, title, excerpt, published_at
FROM articles
WHERE status = :status
  AND (published_at < :last_time
       OR (published_at = :last_time AND id < :last_id))
ORDER BY published_at DESC, id DESC
LIMIT :limit

Create a composite index that begins with the filter and ordering columns, for example an index covering status, published_at, id. Sign or encrypt cursor data and reject cursors whose filter, tenant, user, or sort context no longer matches.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Load pages with IntersectionObserver

Keep the ordinary link and button in the HTML, then enhance it. The loading lock prevents rapid scroll events from requesting the same URL twice.

const feed = document.querySelector('#feed');
const next = document.querySelector('#next');
const button = document.querySelector('#load-more');
const status = document.querySelector('#status');
let loading = false;
let observer;

async function loadNext() {
  if (loading || !next) return;
  loading = true;
  button.disabled = true;
  status.textContent = 'Loading more items';
  const url = next.href;
  try {
    const response = await fetch(url, {
      credentials: 'same-origin',
      headers: { 'X-Requested-With': 'XMLHttpRequest' }
    });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const html = await response.text();
    const parsed = new DOMParser().parseFromString(html, 'text/html');
    const incoming = parsed.querySelectorAll('.items > .card');
    incoming.forEach(card => feed.append(card));
    const replacement = parsed.querySelector('.next');
    if (replacement) {
      next.href = replacement.href;
      next.textContent = replacement.textContent;
    } else {
      next.remove();
      button.remove();
      if (observer) observer.disconnect();
      status.textContent = 'End of results';
      return;
    }
    status.textContent = `${incoming.length} new items`;
  } catch (error) {
    status.textContent = 'Could not load more items. Use Retry or Next.';
    console.error(error);
  } finally {
    loading = false;
    button.disabled = false;
  }
}

button.addEventListener('click', loadNext);
observer = new IntersectionObserver(entries => {
  if (entries.some(entry => entry.isIntersecting)) loadNext();
}, { rootMargin: '400px 0px' });
observer.observe(button);

The server response above includes a complete .items wrapper. If you return JSON instead, validate its shape before rendering:

const data = await response.json();
if (!Array.isArray(data.items) || (data.next !== null && typeof data.next !== 'string')) {
  throw new Error('Invalid pagination response');
}

Abort an in-flight request when the user changes filters, clear the feed, and reset the page or cursor. Keep stable item IDs and deduplicate if a retry can replay a response.

SEO, history, and accessibility

Google Search Central says, “Google generally crawls URLs found in the href attribute of <a> elements.” Every page therefore needs a real URL, a meaningful title, and a sequential next link even when JavaScript appends content. Canonical rules should be deliberate for paginated routes, and you should test the page with JavaScript disabled. Avoid generating unlimited, duplicate combinations of filters.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Use semantic lists or articles, visible focus styles, keyboard-operable controls, and an aria-live status for loading, new-item counts, errors, and completion. Scrolling must not be the only way to discover content: retain the button and ordinary next link. If you update the URL as pages load, use history.pushState only when the resulting URL represents a meaningful state; restore the feed and scroll position when handling the back button.

Performance, reliability, and cost controls

  • Keep page sizes bounded and measure database time, count queries, endpoint latency, response size, and error rate with production-like data.
  • Prefer seek/cursor pagination for unbounded feeds; ensure the ordering columns are indexed and immutable or use an explicit snapshot.
  • Select only fields needed by the card template, compress responses, and cache public pages where authorization permits.
  • Use same-origin credentials deliberately, enforce authorization, rate-limit the endpoint, and log malformed or repeated cursors.
  • Do not issue a count query on every request unless the interface needs it; a full page generally indicates that another page may exist.
  • Reset pagination whenever filters or sorting change. A cursor from one query must never be reused for another.

Troubleshooting common failures

Duplicate items

Concurrent observers or retries usually caused two requests for one URL. Acquire the loading lock before fetch, deduplicate by stable item ID, and use deterministic ordering. Offset pagination can also duplicate rows when new records are inserted; use a cursor or snapshot semantics for a live feed.

Skipped or shifting items

Deletes and inserts change offsets. Use cursor pagination with an indexed immutable order, or accept that offset pages describe a moving list rather than one fixed snapshot.

HTTP 400, 401, or 403

Inspect the requested URL, session credentials, CSRF policy, and authorization query. Do not “fix” the client by trusting arbitrary parameters; correct validation and permission checks on the endpoint.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

HTTP 429 or slow responses

Rate-limit automated loads, increase the observer margin only when appropriate, reduce the batch size, inspect database plans, and add the composite index matching filters and ordering.

Nothing happens on scroll

Check that the sentinel or button exists, the observer is attached after DOM creation, and the next link has a valid absolute or same-origin URL. Test the button directly and run the no-JavaScript fallback.

Malformed fragment or JSON

Confirm the content type, response status, and exact contract. Do not append unvalidated HTML or assume fields exist; log the raw server error separately from the user-facing message.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

If your PHP application needs screenshots of the finished pages rather than a browser automation stack, ScreenshotNeo provides a one-request website screenshot API. Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing status. Its MCP server lets Claude, Cursor, or another MCP client use take_screenshot, get_page_info, and capture_pdf.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

For the complete option list and response details, see the ScreenshotNeo documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

The Free plan includes 1,000 screenshots each month with no card. Paid plans start at $5 for 3,000 shots, and every feature is available on every plan. Sign up free for ScreenshotNeo.

Implementation checklist

  • First HTML response contains the first batch and a real rel="next" link.
  • Every page uses deterministic ordering and a bounded server-side limit.
  • Page, cursor, filters, authorization, and output are validated and escaped.
  • JavaScript locks requests, checks status, validates the response, and updates the next URL.
  • The button and link remain usable with JavaScript disabled.
  • Loading, errors, completion, focus, and new-item counts are announced accessibly.
  • Filters abort stale requests and reset pagination state.
  • Indexes, latency, response size, duplicate IDs, and cursor errors are monitored.

Frequently Asked Questions

Should an infinite-scroll endpoint return HTML or JSON?

Return an HTML fragment when PHP already renders the card markup and progressive enhancement matters most. Choose JSON when several clients share the endpoint or the browser owns templating; validate the response schema before rendering.

Can infinite scroll be SEO-friendly?

Yes, when each batch has its own crawlable URL and the initial document contains ordinary next links. JavaScript should enhance those links, not replace them.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

When should I replace page numbers with cursors?

Use cursors for large or frequently changing feeds when you can provide an indexed, immutable ordering and verify cursor context. Offset pages remain simpler for bounded lists.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.