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

·Matija Žiberna·
How to Pre-build Dynamic Pages with Next.js 15 generateStaticParams

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

6

Frequently Asked Questions

Comments

Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.
Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in