How to Fix Next.js searchParams Killing Static Generation: Separating Dynamic and Static Routes

Split routes so only pages that need searchParams are dynamic; keep the rest SSG/Static

·Matija Žiberna·
How to Fix Next.js searchParams Killing Static Generation: Separating Dynamic and Static Routes

⚡ Next.js Implementation Guides

In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.

No spam. Unsubscribe anytime.

Last week, I was optimizing a PayloadCMS-powered website when I noticed something alarming in the build output. Every single page was being rendered dynamically, despite 90% of the content being completely static. After digging into the Next.js App Router documentation and running multiple build tests, I discovered the culprit: a single searchParams prop in my catch-all route was forcing the entire application into dynamic rendering mode.

The fix required restructuring my routes to separate pages that genuinely need dynamic behavior from those that should be statically generated. This guide walks you through the exact process I used to achieve 90% static generation while maintaining full dynamic functionality for product variant filtering.

The Problem: searchParams Forces Dynamic Rendering on All Pages

I had a typical Next.js App Router setup with a catch-all route handling multiple page types. The route structure looked like this:

// File: src/app/(frontend)/[...slug]/page.tsx
export default async function SlugPage({
  params: paramsPromise,
  searchParams: searchParamsPromise,
}: {
  params: Promise<{ slug?: string[] }>;
  searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
  const params = await paramsPromise;
  const searchParams = await searchParamsPromise;
  const { slug } = params;

  // Handle different page types
  const pageType = getPageTypeFromSlug(slug);

  // Product pages used searchParams for variant filtering
  // But general pages, service pages, and project pages didn't need it

  const page = await getPageBySlug(slug);
  return <>{renderPageBlocks(page, searchParams)}</>;
}

This single route handled everything: homepage, general pages, service pages, project pages, and product pages. The product pages needed searchParams for filtering product variants by color, size, and other attributes. But here's the critical issue I discovered in the Next.js documentation: any page component that accepts searchParams cannot be statically generated.

When I ran pnpm run build, the output confirmed my suspicions:

Route (app)                              Size  First Load JS
├ ƒ /                                   260 B         373 kB
├ ƒ /[...slug]                          260 B         373 kB
├ ƒ /storitve/[slug]                    260 B         373 kB

ƒ  (Dynamic)  server-rendered on demand

Every page marked with ƒ (Dynamic) meant zero static generation benefits. My homepage, about page, contact page—all being server-rendered on every request despite having completely static content.

Understanding the Static Generation Constraint

Next.js App Router has a strict rule: pages that use searchParams must be rendered dynamically because the search parameters are only known at request time. This makes complete sense for pages that genuinely need dynamic filtering or state management through URL parameters. But in my case, only product pages needed this functionality.

The problem was architectural. By handling all page types in a single catch-all route that accepted searchParams, I had inadvertently forced every page through dynamic rendering. The solution required separating concerns: static pages should live in one route structure, and dynamic pages requiring searchParams should have their own dedicated route.

Step 1: Analyzing Which Pages Actually Need searchParams

Before making any changes, I audited exactly which pages used the searchParams functionality. The breakdown looked like this:

Pages NOT using searchParams (90% of content):

  • Homepage (/)
  • General pages (/about, /contact, /privacy-policy)
  • Service pages (/storitve/[slug])
  • Project pages (/projekti/[slug])

Pages USING searchParams (10% of content):

  • Product pages (/izdelki/[slug]?color=red&size=large)

The product pages needed searchParams because they implemented variant filtering. When users selected a color or size option, the ProductVariantSelector component updated the URL with query parameters, and the server component read those parameters to display the correct variant pricing and availability.

This 90/10 split made the solution clear: extract product pages into their own route and let the catch-all route be fully static.

Step 2: Creating a Dedicated Dynamic Route for Products

The first step was creating a new route specifically for product pages at /src/app/(frontend)/izdelki/[slug]/page.tsx. This route would be the only one accepting searchParams.

// File: src/app/(frontend)/izdelki/[slug]/page.tsx
import { draftMode } from "next/headers";
import { notFound } from "next/navigation";
import React from "react";
import type { Metadata } from "next";

import { queryProductPageBySlug, queryAllProductSlugs } from "@/lib/payload";
import type { ProductPage } from "@payload-types";
import { RenderProductPageBlocks } from "@/blocks/RenderProductPageBlocks";
import { generatePageSEOMetadata } from "@/utilities/seo";
import { getOgParamsFromPage, getOgImageUrl } from "@/lib/og-image";

type Props = {
  params: Promise<{ slug: string }>;
  searchParams: Promise<Record<string, string | string[] | undefined>>;
};

export async function generateStaticParams() {
  try {
    const slugs = await queryAllProductSlugs();
    return slugs.map((slug: string) => ({
      slug,
    }));
  } catch (error) {
    console.error("Error generating static params for products:", error);
    return [];
  }
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const resolvedParams = await params;
  const { slug } = resolvedParams;

  let page: ProductPage | null = null;

  try {
    page = await queryProductPageBySlug({ slug, overrideAccess: false, draft: false });
  } catch (error) {
    console.error("Error fetching product page for metadata:", error);
  }

  if (!page) {
    return {
      title: "Izdelek - Laneks",
      description: "Profesionalne storitve za vaš dom in podjetje",
    };
  }

  let ogImageUrl: string | undefined = undefined;

  if (page) {
    const ogParams = await getOgParamsFromPage(page);
    if (ogParams) {
      ogImageUrl = getOgImageUrl(ogParams);
    }
  }

  return generatePageSEOMetadata(page, [slug], { ogImageUrl });
}

export default async function ProductPage({
  params: paramsPromise,
  searchParams: searchParamsPromise,
}: Props) {
  const params = await paramsPromise;
  const searchParams = await searchParamsPromise;
  const { slug } = params;

  const { isEnabled: draft } = await draftMode();

  const page = await queryProductPageBySlug({
    slug,
    overrideAccess: false,
    draft
  });

  if (!page) {
    return notFound();
  }

  return (
    <RenderProductPageBlocks
      pageType={page.pageType}
      blocks={page.layout}
      searchParams={searchParams}
    />
  );
}

This dedicated product route maintains all the dynamic functionality needed for variant filtering. The key difference from the catch-all route is that this one expects a single slug string rather than an array, and it's specifically designed to handle product pages only.

Notice that we still use generateStaticParams() even though this route accepts searchParams. This generates static HTML for the base product URLs without query parameters. When users add filter parameters like ?color=red, Next.js dynamically renders those variations while still benefiting from static generation for the initial page load.

Step 3: Removing searchParams from the Catch-All Route

With product pages handled separately, I could now remove all searchParams logic from the main catch-all route. This required several coordinated changes.

First, I updated the Props interface to remove searchParams:

// File: src/app/(frontend)/[...slug]/page.tsx
type Props = {
  params: Promise<{ slug?: string[] }>;
  // searchParams removed - no longer needed
};

Next, I removed product page handling from the route configuration:

// Before
const ROUTE_CONFIGS = {
  storitve: "service",
  projekti: "project",
  izdelki: "product",
} as const;

// After
const ROUTE_CONFIGS = {
  storitve: "service",
  projekti: "project",
} as const;

This constant maps URL prefixes to page types. Removing izdelki meant the catch-all route would no longer attempt to handle product pages, delegating that responsibility to the dedicated route.

I updated the type definitions to reflect the removal:

// Before
type AnyPageType = Page | ServicePage | ProjectPage | ProductPage;

// After
type AnyPageType = Page | ServicePage | ProjectPage;

Then I removed product-specific logic from the query function:

// Before
switch (pageType) {
  case "service":
    return queryServicePageBySlug({ slug: slug[1], overrideAccess, draft });
  case "project":
    return queryProjectPageBySlug({ slug: slug[1], overrideAccess, draft });
  case "product":
    return queryProductPageBySlug({ slug: slug[1], overrideAccess, draft });
  default:
    return queryPageBySlug({ slug, overrideAccess, draft });
}

// After
switch (pageType) {
  case "service":
    return queryServicePageBySlug({ slug: slug[1], overrideAccess, draft });
  case "project":
    return queryProjectPageBySlug({ slug: slug[1], overrideAccess, draft });
  default:
    return queryPageBySlug({ slug, overrideAccess, draft });
}

Finally, I updated the render function to remove searchParams entirely:

// Before
function renderPageBlocks(
  page: AnyPageType,
  searchParams: Record<string, string | string[] | undefined>,
) {
  switch (page.pageType) {
    case "service":
      return <RenderServicesPageBlocks pageType={page.pageType} blocks={page.layout} searchParams={searchParams} />;
    // ... other cases
  }
}

// After
function renderPageBlocks(page: AnyPageType) {
  switch (page.pageType) {
    case "service":
      return <RenderServicesPageBlocks pageType={page.pageType} blocks={page.layout} />;
    // ... other cases
  }
}

And updated the main component signature:

// File: src/app/(frontend)/[...slug]/page.tsx
export default async function SlugPage({
  params: paramsPromise,
}: {
  params: Promise<{ slug?: string[] }>;
}) {
  const params = await paramsPromise;
  // No searchParams destructuring needed anymore

  // ... rest of implementation
  return <>{renderPageBlocks(page)}</>;
}

These changes systematically removed all product page handling and searchParams dependencies from the catch-all route, making it eligible for static generation.

Step 4: Updating Static Path Generation

With the route separation complete, I needed to ensure that the static path generation logic didn't create conflicts. The issue was that both routes had generateStaticParams() functions, and I needed to make sure they didn't overlap.

I updated the main static paths function to exclude product pages:

// File: src/lib/payload/index.ts
export const getStaticPaths = async (): Promise<{ slug: string[] }[]> => {
  return await unstable_cache(
    async () => {
      const payload = await getPayloadClient();
      const staticPaths: { slug: string[] }[] = [];

      try {
        const pages = await payload.find({
          collection: "pages",
          limit: 1000,
          where: { _status: { equals: "published" } },
        });

        const servicePages = await payload.find({
          collection: "service-pages",
          limit: 1000,
          where: { _status: { equals: "published" } },
        });

        const projectPages = await payload.find({
          collection: "project-pages",
          limit: 1000,
          where: { _status: { equals: "published" } },
        });

        // Add regular pages (excluding home page - handled by dedicated /page.tsx)
        pages.docs.forEach((page) => {
          if (page.slug && typeof page.slug === "string" && page.slug !== "home") {
            staticPaths.push({ slug: [page.slug] });
          }
        });

        // Add service pages with language prefixes
        const serviceLanguages = ["storitve", "tretmaji"];
        servicePages.docs.forEach((page) => {
          if (page.slug && typeof page.slug === "string") {
            serviceLanguages.forEach((lang) => {
              staticPaths.push({ slug: [lang, page.slug as string] });
            });
          }
        });

        // Add project pages with language prefixes
        const projectLanguages = ["projekti"];
        projectPages.docs.forEach((page) => {
          if (page.slug && typeof page.slug === "string") {
            projectLanguages.forEach((lang) => {
              staticPaths.push({ slug: [lang, page.slug as string] });
            });
          }
        });

        // Product pages removed - handled by dedicated route

        return staticPaths;
      } catch (error) {
        console.error("Error generating static params:", error);
        return [];
      }
    },
    [CACHE_KEY.STATIC_PATHS()],
    {
      tags: [TAGS.PAGES, TAGS.SERVICE_PAGES, TAGS.PROJECT_PAGES],
      revalidate: false,
    },
  )();
};

The critical change here is removing the product pages from the static paths generation for the catch-all route. This prevents the Next.js build error "The provided export path doesn't match the page" that occurs when multiple routes try to generate the same path.

I also needed to exclude the homepage from the catch-all route's static params since it's handled by a dedicated /page.tsx file. This is a common pattern in Next.js where the root page uses the catch-all route's logic but is defined separately.

Then I created a new function to generate product slugs for the dedicated route:

// File: src/lib/payload/index.ts
export const queryAllProductSlugs = async (): Promise<string[]> => {
  return await unstable_cache(
    async () => {
      const payload = await getPayloadClient();
      const productPages = await payload.find({
        collection: "product-pages",
        limit: 1000,
        where: { _status: { equals: "published" } },
        select: { slug: true },
      });

      return productPages.docs
        .filter((page) => page.slug && typeof page.slug === "string")
        .map((page) => page.slug as string);
    },
    [CACHE_KEY.PRODUCT_SLUGS()],
    {
      tags: [TAGS.PRODUCT_PAGES],
      revalidate: false,
    },
  )();
};

This function is called by the product route's generateStaticParams() to get all product slugs for static generation. By separating this logic, each route has clear ownership of its static path generation.

I also added the corresponding cache key:

// File: src/lib/payload/cache-keys.ts
export const CACHE_KEY = {
  // ... other keys
  PRODUCT_SLUGS: () => "product-slugs-all",
};

Step 5: Fixing the Homepage Implementation

One tricky aspect of this refactoring was the homepage. In Next.js App Router, you typically have a root /page.tsx that handles the homepage separately from dynamic routes. My implementation used an elegant pattern to reuse the catch-all route logic:

// File: src/app/(frontend)/page.tsx
import PageTemplate, { generateMetadata } from './[...slug]/page'

export default PageTemplate

export { generateMetadata }

This works because when Next.js calls the PageTemplate component from the root page, it automatically provides the params as an empty slug array, which the catch-all route interprets as the homepage. This approach avoids code duplication while maintaining clean separation of concerns.

However, after removing searchParams from the catch-all route, this pattern continued to work seamlessly without any changes needed. This demonstrates one of the benefits of the refactoring—the interfaces became simpler and more predictable.

Step 6: Updating Component Interfaces

The final implementation step involved updating all the render block components to make searchParams optional. This maintains backward compatibility while reflecting the new architecture.

// File: src/blocks/RenderServicesPageBlocks.tsx
export const RenderServicesPageBlocks: React.FC<{
  pageType: ServicePage["pageType"];
  blocks: ServicePage["layout"];
  searchParams?: Record<string, string | string[] | undefined>; // Now optional
}> = (props) => {
  const { blocks, pageType, searchParams } = props;

  // Component implementation
};

I made the same change to RenderGeneralPageBlocks and RenderProjectPageBlocks. While these components no longer receive searchParams from the catch-all route, making it optional rather than removing it entirely provides flexibility for future use cases and maintains the component interface consistency.

For the product blocks, searchParams remains required since it's essential functionality:

// File: src/blocks/RenderProductPageBlocks.tsx
export const RenderProductPageBlocks: React.FC<{
  pageType: ProductPage["pageType"];
  blocks: ProductPage["layout"];
  searchParams: Record<string, string | string[] | undefined>; // Required
}> = (props) => {
  // Product-specific implementation that uses searchParams
};

Step 7: Resolving TypeScript and Build Errors

After making all the structural changes, I ran into several TypeScript compilation errors that needed resolution. The first was in the preview route handler:

Type error: Type 'typeof import("/src/app/(frontend)/next/preview/route")' does not satisfy the constraint 'RouteHandlerConfig<"/next/preview">'.
  Types of property 'GET' are incompatible.

This error was unrelated to the route refactoring but surfaced during the build. The issue was that the preview route was using a custom request type instead of Next.js's NextRequest:

// File: src/app/(frontend)/next/preview/route.ts
// Before
export async function GET(
  req: {
    cookies: {
      get: (name: string) => { value: string; };
    };
  } & Request,
): Promise<Response> {

// After
import { NextRequest } from "next/server";

export async function GET(req: NextRequest): Promise<Response> {
  // Implementation
}

Using NextRequest resolves the type incompatibility and follows Next.js best practices for route handlers.

I also needed to update the TypeScript hints in RenderProjectPageBlocks to avoid unused @ts-expect-error directive warnings:

// File: src/blocks/RenderProjectPageBlocks.tsx
<Block
  {...block}
  // @ts-expect-error disableInnerContainer may not exist on all blocks
  disableInnerContainer
  // @ts-expect-error searchParams may not exist on all blocks
  searchParams={searchParams}
/>

This properly documents why we're suppressing TypeScript errors for props that may not exist on all block component types.

Verifying the Results

After completing all the changes, I ran the production build to verify the optimization worked:

pnpm run build

The build output showed exactly what I was hoping for:

Route (app)                              Size  First Load JS  Revalidate
┌ ○ /                                   254 B         377 kB         15m
├ ● /[...slug]                          254 B         377 kB         15m
├   ├ /politika-piskotkov                                             15m
├   ├ /pogoji-poslovanja                                              15m
├   └ [+14 more paths]
├ ● /izdelki/[slug]                   20.9 kB         405 kB         15m
├   ├ /izdelki/cistilna-naprava-roeco-8-6000l                         15m
├   ├ /izdelki/cistilna-naprava-roeco-5-4000l                         15m
└   └ [+2 more paths]

○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML (uses generateStaticParams)

The symbols tell the story:

  • (Static) - The homepage is now fully static
  • (SSG) - The catch-all route and product route both use static site generation
  • No more ƒ (Dynamic) markers on content pages

This represents a massive performance improvement. The homepage, general pages, service pages, and project pages are now pre-rendered at build time and served as static HTML. Product pages are also statically generated for their base URLs, with dynamic rendering only happening when users interact with variant filters and add search parameters to the URL.

Key Takeaways and Best Practices

Through this refactoring process, I learned several important lessons about Next.js App Router performance optimization:

searchParams has a global impact. The moment you accept searchParams in a page component, that entire route becomes ineligible for static generation. This isn't a bug—it's by design. Search parameters are request-time values, so Next.js must render the page dynamically to access them. Be very intentional about which pages truly need this functionality.

The 90/10 rule applies to routing. Don't let 10% of your pages that need dynamic behavior force the other 90% into dynamic rendering. Separate routes based on rendering requirements, not just on content organization. In my case, product pages were a distinct minority of total content, so giving them their own route made architectural sense.

Route separation improves performance without breaking functionality. You can still use generateStaticParams with routes that accept searchParams. The base URL gets statically generated, and only the query parameter variations render dynamically. This gives you the best of both worlds.

TypeScript strictness catches architectural issues. When I removed searchParams from the catch-all route, TypeScript immediately flagged every place where the interface had changed. This forced me to consciously update each component, preventing runtime errors from mismatched prop expectations.

The export path mismatch error reveals route conflicts. If you see "The provided export path doesn't match the page" during build, it means multiple routes are trying to generate the same static path. This typically happens when you have overlapping generateStaticParams functions. The solution is to ensure each route has exclusive ownership of its paths.

Homepage reuse patterns work elegantly. The pattern of exporting the catch-all route's component and metadata function from the root page.tsx is cleaner than duplicating logic. It automatically handles the empty slug case without special configuration.

This refactoring took a few hours of careful implementation and testing, but the performance gains are substantial. Static generation means faster page loads, better SEO crawling, reduced server costs, and improved user experience for the vast majority of site visitors.

If your Next.js application uses a catch-all route with searchParams, audit which pages actually need that functionality. You might find, as I did, that a simple route separation unlocks significant performance improvements.

Let me know in the comments if you've encountered similar searchParams performance issues, and subscribe for more practical Next.js optimization guides.

Thanks, Matija

0

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

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