Recommended Free Tools
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.
#1 Best Overall
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.
Rank #2
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.
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.
Quick wins for a faster PC:
Repair Windows errors before they cause bigger problemsFix Now →Fix the driver behind crashes, sound loss and screen glitchesFind Drivers →Clear out junk files and repair common Windows errorsFree Scan →Rank #4
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.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.
Windows Errors? Fix Them Before They Spread
Repair common Windows errors and clear accumulated junk for a smoother, more stable PC - no reinstall needed.Free scan · no reinstallCrashes, No Sound, or Screen Glitches?
Random freezes, missing sound and display glitches usually trace back to one bad driver. Find and replace yours safely.Free scan · under a minuteFor 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.
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.
Quick Recap
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.

