- How to Pre-build Dynamic Pages with Next.js 15 generateStaticParams
How to Pre-build Dynamic Pages with Next.js 15 generateStaticParams
Transform slow server-rendered pages into lightning-fast static HTML with proper pagination handling

I was building an e-commerce site with 200+ products when I hit a performance wall. Every product page was server-rendered on demand, which meant users waited 2-3 seconds for each page to load. After implementing generateStaticParams
, page loads dropped to under 200ms. This guide shows you exactly how to transform your dynamic pages into lightning-fast static HTML.
The Performance Problem
My Next.js 15 e-commerce application had the typical dynamic route setup:
app/products/[handle]/page.tsx
app/collections/[handle]/page.tsx
When users visited /products/cool-shirt
, Next.js would fetch product data from Shopify's API on-demand, then render the page. This approach works but creates unnecessary delays. Every single page visit required a fresh API call and server rendering.
The solution was generateStaticParams
- a Next.js function that pre-builds all your dynamic pages at build time. Instead of rendering pages when users request them, you serve pre-built HTML from a CDN. The difference in speed is dramatic.
Planning the Implementation
Before writing any code, I had to solve three critical questions that every developer implementing static generation faces.
How many pages need to be generated? The answer directly impacts your build strategy. With 200 products, I needed an efficient paginated approach to fetch all product handles without overwhelming the API or extending build times unnecessarily.
What happens when new products are added after deployment? Since generateStaticParams
runs at build time, new products added later wouldn't have pre-built pages. I decided to enable dynamicParams = true
, allowing Next.js to server-render new pages on-demand while keeping the pre-built pages static.
How do we maintain fast development builds? Generating 200+ pages during local development would make npm run dev
painfully slow. I implemented an environment variable (SKIP_STATIC_GENERATION
) to disable static generation locally while keeping it enabled for production builds.
Choosing the Data-Fetching Strategy
The key challenge was efficiently fetching all product handles (slugs) needed for generateStaticParams
. I evaluated three approaches and learned some important lessons about API optimization.
The naive approach would have been using my existing getProducts
function to fetch complete product data, then extracting just the handles. This seemed convenient since the function already existed, but it would have been wasteful - pulling dozens of fields like descriptions, prices, and images for 200+ products just to use a single handle
field.
The optimized approach was creating a dedicated GraphQL query that requests only essential data: the handle
for URL generation and tags
for filtering out hidden products. This minimizes bandwidth and speeds up the build process significantly.
The pagination requirement sealed the decision. Most APIs, including Shopify's, limit responses to 250 items per request. To fetch all product handles, I needed a solution that could handle multiple paginated requests automatically until all data was retrieved.
Building the Implementation
The implementation combines an optimized GraphQL query with automatic pagination handling. Each step builds toward a complete solution that can handle hundreds of products efficiently.
Creating the Optimized GraphQL Query
The first step is creating a lightweight GraphQL query that requests only the essential data needed for static generation.
// lib/shopify/queries.ts
export const getProductHandlesQuery = /* GraphQL */ `
query getProductHandles($first: Int = 250, $after: String, $query: String) {
products(first: $first, after: $after, query: $query) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
handle
tags
}
}
}
}
`;
This query is specifically designed for static generation efficiency. It requests only the handle
field (needed for URL generation) and tags
(for filtering hidden products). The pageInfo
fields enable pagination handling, while the $query
parameter allows filtering products during the API request itself.
Building the Pagination Handler
The core of the solution is a function that automatically handles API pagination to fetch all product handles in batches.
// lib/shopify/index.ts
import { getProductHandlesQuery } from "./queries";
import { shopifyFetch } from "./api";
import { HIDDEN_PRODUCT_TAG } from "../constants";
type ShopifyProductHandle = { handle: string; tags: string[] };
export async function getAllProductHandles(): Promise<string[]> {
if (process.env.SKIP_STATIC_GENERATION === "true") {
console.log("⚠️ Skipping product static generation in development mode.");
return [];
}
const allHandles: string[] = [];
let hasNextPage = true;
let after: string | null = null;
console.log("🚀 Fetching all product handles for static generation...");
while (hasNextPage) {
const { body } = await shopifyFetch<any>({
query: getProductHandlesQuery,
variables: {
first: 250,
after,
query: `-tag:${HIDDEN_PRODUCT_TAG}`,
},
});
const products = body.data.products.edges.map((edge: any) => edge.node);
const handles = products.map(
(product: ShopifyProductHandle) => product.handle,
);
allHandles.push(...handles);
hasNextPage = body.data.products.pageInfo.hasNextPage;
after = body.data.products.pageInfo.endCursor;
console.log(
`📦 Fetched ${handles.length} product handles (Total: ${allHandles.length})`,
);
}
console.log(
`✅ Successfully fetched ${allHandles.length} total product handles.`,
);
return allHandles;
}
This function implements the pagination logic that automatically fetches all product handles across multiple API requests. The while
loop continues until hasNextPage
is false, using the endCursor
from each response as the starting point for the next request. The development environment check allows you to skip this process locally for faster builds.
Implementing generateStaticParams in Your Page
With the data-fetching infrastructure in place, you can now implement static generation in your dynamic page component.
// app/products/[handle]/page.tsx
import { getAllProductHandles, getProduct } from "@/lib/shopify";
import { Suspense } from "react";
export async function generateStaticParams() {
const handles = await getAllProductHandles();
return handles.map((handle) => ({ handle }));
}
export const dynamicParams = true;
export default async function ProductPage({
params,
}: {
params: { handle: string };
}) {
const product = await getProduct(params.handle);
if (!product) {
return <div>Product not found</div>;
}
return (
<Suspense fallback={<div>Loading...</div>}>
<main>
<h1>{product.title}</h1>
<p>{product.description}</p>
{/* ... rest of your component */}
</main>
</Suspense>
);
}
The generateStaticParams
function runs at build time and returns an array of parameter objects that Next.js uses to pre-build static pages. Setting dynamicParams = true
enables fallback rendering for any product pages that weren't pre-built, giving you the best of both worlds.
Setting Up Development Environment Optimization
To maintain fast development builds, configure your local environment to skip static generation:
# .env.local
SKIP_STATIC_GENERATION=true
This environment variable allows the getAllProductHandles
function to return an empty array during development, preventing the lengthy build process while preserving the static generation behavior in production.
Solving the Build-Time Error
After implementing the static generation, my production build failed with a confusing error that taught me an important lesson about mixing static and dynamic content.
The error message was: useSearchParams() should be wrapped in a suspense boundary at page "/products/[handle]"
. This happened because I had a client component deep in my page component tree that used the useSearchParams()
hook to read URL query parameters for product variant selection.
Why this fails during static generation is crucial to understand. When Next.js pre-builds pages at build time, there are no search parameters available. The build process doesn't know what ?variant=red&size=large
might be for a specific product page, so it can't render components that depend on this dynamic data.
The debugging process started with the error message itself. I searched my codebase for useSearchParams()
and found it in a ProductProvider
component that managed product variants based on URL parameters. This component was essential for the user experience but incompatible with static generation.
The solution was wrapping the dynamic parts in a <Suspense>
boundary. This tells Next.js to statically build the main page structure while streaming in the dynamic components on the client side.
// app/products/[handle]/page.tsx
import { ProductProvider } from "@/components/product/product-context";
import { Suspense } from "react";
export default async function ProductPage({
params,
}: {
params: { handle: string };
}) {
const product = await getProduct(params.handle);
return (
<>
<script type="application/ld+json">{/* SEO data */}</script>
<Suspense fallback={<div>Loading product options...</div>}>
<ProductProvider>
<h1>{product.title}</h1>
{/* All dynamic components go here */}
</ProductProvider>
</Suspense>
</>
);
}
This approach gives you the performance benefits of static generation for the main content while preserving the dynamic functionality users need. The static HTML loads instantly, and the interactive elements stream in seamlessly.
The Final Result
After implementing this solution, my e-commerce site now generates all 200+ product pages statically at build time. Page load times dropped from 2-3 seconds to under 200ms, and the user experience improved dramatically. New products added to Shopify are still accessible thanks to dynamicParams = true
, which renders them on-demand.
The architecture is clean and maintainable. The lib/shopify/queries.ts
file contains optimized GraphQL queries, while lib/shopify/index.ts
handles the pagination logic. Each dynamic page uses generateStaticParams
with proper Suspense
boundaries for components that need client-side interactivity.
Most importantly, development builds remain fast thanks to the SKIP_STATIC_GENERATION
environment variable, giving you the performance benefits in production without slowing down your development workflow.
This approach works for any headless CMS - whether you're using Shopify, Contentful, Sanity, or Strapi. The key principles remain the same: create lightweight queries, handle pagination efficiently, and use Suspense boundaries for dynamic client components.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks, Matija