Building SEO-Friendly Blog Pagination with Load More and Next/Prev Links in Next.js
Adam C. |

When building a blog or content listing in Next.js, it's tempting to rely solely on client-side interactivity — such as a “Load More” button powered by JavaScript. But for Google SEO and crawlability, this alone isn't enough.

Photo by Amol Srivastava on Unsplash

In this post, we’ll walk through how to:

Use a “Load More” button that saves posts to localStorage for better UX when navigating back

Fall back to traditional <Prev> / <Next> links when presenting a paginated view (e.g., ?page=3)

Ensure all links are visible to search engines by rendering <a href="..."> inside <Link>

💡 Why This Matters

Search engines like Google don’t always execute JavaScript. So if your site loads more posts with just a button and no actual <a href> to another page, Google won’t crawl or index those additional pages.

Good UX requires JavaScript. Good SEO requires HTML.

✅ Strategy Summary

Page 1 starts with SSR-rendered initial posts.

Clicking “Load More” loads more posts via API and appends them to the current list.

All loaded posts are cached to localStorage.

When viewing a post and hitting back, we restore from localStorage to avoid re-fetching.

When visiting ?page=n via a real URL, we show Prev / Next buttons as <a href> links.

We render real <a> elements inside <Link>, so Google sees the next pages.

🧱 Code Example (Blog Listing with SEO + UX)

pages/blog/index.js

import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import Link from "next/link";
import { Segment, Button } from "semantic-ui-react";
import axios from "axios";

export default function BlogPage({ initialPosts, initialPage, initialShowMore }) {
  const router = useRouter();
  const [posts, setPosts] = useState(() => {
    if (typeof window !== "undefined") {
      const cached = localStorage.getItem("blogPosts");
      return cached ? JSON.parse(cached) : initialPosts;
    }
    return initialPosts;
  });
  const [currentPage, setCurrentPage] = useState(initialPage);
  const [showMore, setShowMore] = useState(initialShowMore);
  const [isLoading, setIsLoading] = useState(false);

  const isPaginatedView = !!router.query.page;

  useEffect(() => {
    // On mount, if paginated view, overwrite cache
    if (isPaginatedView) {
      localStorage.setItem("blogPosts", JSON.stringify(initialPosts));
    }
  }, [initialPosts, isPaginatedView]);

  const loadMore = async () => {
    setIsLoading(true);
    const nextPage = currentPage + 1;
    const res = await axios.get(`/api/posts?page=${nextPage}`);
    const newPosts = [...posts, ...res.data.posts];
    setPosts(newPosts);
    setCurrentPage(nextPage);
    setShowMore(res.data.hasMore);
    localStorage.setItem("blogPosts", JSON.stringify(newPosts));
    setIsLoading(false);
    router.replace("/blog"); // clean up ?page= after JS
  };

  const prevPage = currentPage > 1 ? currentPage - 1 : null;
  const nextPage = currentPage + 1;

  return (
    <div className="blog-wrapper">
      {posts.map((post) => (
        <div key={post.id}>
          <h3>
            <Link href={`/blog/${post.slug}`}>
              <a>{post.title}</a>
            </Link>
          </h3>
          <p>{post.excerpt}</p>
        </div>
      ))}

      {!isPaginatedView && showMore && !isLoading && (
        <Segment textAlign="center">
          <Link href={`/blog?page=${nextPage}`}>
            <a onClick={(e) => {
              e.preventDefault();
              loadMore();
            }}>
              <Button primary>Load More</Button>
            </a>
          </Link>
        </Segment>
      )}

      {isPaginatedView && (
        <Segment textAlign="center">
          {prevPage && (
            <Link href={`/blog?page=${prevPage}`}>
              <a>
                <Button secondary>Previous Page</Button>
              </a>
            </Link>
          )}
          {showMore && (
            <Link href={`/blog?page=${nextPage}`}>
              <a>
                <Button secondary>Next Page</Button>
              </a>
            </Link>
          )}
        </Segment>
      )}
    </div>
  );
}

getServerSideProps

export async function getServerSideProps(context) {
  const page = parseInt(context.query.page || "1", 10);
  const pageSize = 20;

  const res = await fetch(`https://yourdomain.com/api/posts?page=${page}`);
  const data = await res.json();

  return {
    props: {
      initialPosts: data.posts,
      initialPage: page,
      initialShowMore: data.hasMore,
    },
  };
}

✅ Best Practices Summary

Use <Link><a>...</a></Link> for SEO — never wrap <Button> directly

Use localStorage to cache posts, improving the "Back" experience

SSR-render each page (?page=n) so crawlers can reach it

Avoid infinite loops in useEffect by keeping dependencies controlled

Use router.replace('/blog') to clean up query strings after Load More

🚫 A Common Mistake: Trusting Eslint Uncritically

You might see a warning:

React Hook useEffect has missing dependencies: 'initialPosts', 'currentPage', etc.

Be careful: if you add all those as dependencies, you may accidentally cause infinite re-renders. It’s fine to intentionally ignore the warning in some scenarios where you’re controlling when updates should happen.

Conclusion

This hybrid approach gives you the best of both worlds:

Instant “Load More” interactivity for users

Crawlable <Prev> / <Next> links for search engines

Cached session state for great user experience when navigating back

It’s not magic — just careful balance between JavaScript UX and HTML SEO.

Let me know if you want this packaged as a markdown post or published to your blog!