A bug on the homepage surfaced recently – the kind that’s easy to write by accident and easy to miss in review: the page pre-rendered its data at build time, then threw that data away and fetched it again in the browser.
This post walks through what the bug looked like, why it hurts performance, and how it was fixed.
The Problem
pages/index.js used getStaticProps to fetch the first page of blog posts at build time – exactly what you want for a statically generated blog:
// pages/index.js (before)
export async function getStaticProps() {
const posts = await getPosts(1, 10);
return { props: { posts } };
}
const Home = () => {
const [sitePosts, setSitePosts] = useState([]); // starts empty!
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const fetchPosts = async () => {
if (loading) return;
setLoading(true);
try {
const response = await getPosts(page, process.env.NEXT_PUBLIC_POSTS_PER_PAGE);
setSitePosts((prevPosts) => [...prevPosts, ...response.data]);
setPage((prevPage) => prevPage + 1);
// ...
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPosts(); // fetch page 1 again, on the client, after mount
}, []);
return (
<ul>
<RenderData data={sitePosts} />
</ul>
);
};
export default Home;
Look closely at the Home component’s signature:
const Home = () => {
It takes no props. The posts object that getStaticProps worked hard to fetch at build time is returned from getStaticProps, embedded in the page’s JSON payload, shipped to the browser… and then never read. Instead, sitePosts starts as an empty array and a useEffect re-fetches page 1 from scratch as soon as the component mounts.
Why This Hurts Performance
This single mistake stacks three separate problems on top of each other:
1. A wasted round trip on every single page load
The whole point of getStaticProps is to do the expensive work (calling the WordPress REST API) once, at build/revalidate time, so visitors get pre-rendered HTML instantly. By re-fetching the same data client-side, every visitor pays for an API call that had already been paid for. That’s an extra network round trip, on the critical path, for zero benefit.
2. A visible empty state / content flash
Because sitePosts starts as [], the very first render – including the HTML that gets sent to the browser and hydrated – shows no posts at all. Only after the client-side fetch resolves does the list populate. On a fast connection this might look like a quick flicker; on a slow connection or a slow WordPress backend, visitors can stare at an empty “Blog Posts” heading for a second or more, even though the fully-formed HTML was sitting right there in the static page.
This is a self-inflicted Largest Contentful Paint (LCP) and Cumulative Layout Shift (CLS) regression – the content that should have painted immediately is now gated behind a client-side fetch.
3. Extra load on the WordPress API for no reason
Every page load now triggers a duplicate request to the WordPress REST API – doubling API traffic for the homepage’s first page of posts, with no caching benefit since it re-runs on every mount.
In short: this pattern quietly converts a fast, pre-rendered static page into a slower client-rendered one, while still paying the build-time cost of static generation. It’s the worst of both worlds.
The Fix
The fix is to actually use the data getStaticProps already fetched, and only reach for the client-side fetch when the user explicitly asks for more posts (pagination via the “Show more posts” button):
// pages/index.js (after)
const POSTS_PER_PAGE = process.env.NEXT_PUBLIC_POSTS_PER_PAGE;
export async function getStaticProps() {
const posts = await getPosts(1, POSTS_PER_PAGE);
return { props: { posts } };
}
const Home = ({ posts }) => {
// Seed state from the statically generated data - no initial fetch needed
const [sitePosts, setSitePosts] = useState(posts.data);
const [page, setPage] = useState(2); // page 1 was already loaded via getStaticProps
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(posts.data.length < posts.totalPosts);
const fetchPosts = async () => {
if (loading) return;
setLoading(true);
try {
const response = await getPosts(page, POSTS_PER_PAGE);
const postData = response.data;
setSitePosts((prevPosts) => [...prevPosts, ...postData]);
setPage((prevPage) => prevPage + 1);
if (postData.length < POSTS_PER_PAGE) {
setHasMore(false);
}
} catch (error) {
console.error('Error fetching posts:', error);
} finally {
setLoading(false);
}
};
// No useEffect / no fetch-on-mount - fetchPosts only runs on "Show more" clicks
return (
<div>
<h1 className='text-xl font-medium mb-5'>Blog Posts</h1>
<ul className='grid grid-cols-1 gap-7 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3'>
<RenderData data={sitePosts} />
</ul>
{hasMore && (
<div className='mt-10 text-center'>
<button onClick={fetchPosts} disabled={loading}>
{loading ? 'Loading...' : 'Show more posts'}
</button>
</div>
)}
{!hasMore && <p>No more posts to load.</p>}
</div>
);
};
export default Home;
What changed:
Homenow destructures{ posts }from its props instead of ignoring them.sitePostsis seeded withposts.datafromgetStaticProps, so the first render already has content – no empty state, no flash.pagestarts at2, since page 1 is already loaded. The client only fetches page 2 onward, and only when the visitor clicks “Show more posts.”- The
useEffect(() => { fetchPosts(); }, [])on mount is gone entirely – there’s nothing to fetch on mount anymore. - As a bonus fix,
getStaticPropsnow uses the samePOSTS_PER_PAGEenv var as the client-side fetch (it was previously hardcoded to10), so page numbering stays consistent and “Show more” can’t skip or duplicate posts.
The Result
| Before | After | |
|---|---|---|
| Requests to load the homepage’s first page of posts | 2 (1 at build/revalidate time + 1 on every client mount) | 1 (at build/revalidate time only) |
| First paint of post list | Empty, then populated after client fetch resolves | Fully populated immediately (from static HTML) |
| WordPress API calls per homepage visit | 1 extra, every visit | 0 extra |
Takeaway
getStaticProps (or any server-side data fetching in Next.js) only pays off if the component on the other end actually consumes what it returns. It’s worth double-checking, especially in components that also do client-side fetching for pagination or “load more” behavior, that the initial page of data isn’t being fetched twice – once on the server for free, and once again on the client for nothing.