Next.js Discord

Discord Forum

`cacheComponents: true` is incorrectly requiring Suspense boundaries for `useSearchParams()`

Unanswered
Cape lion posted this in #help-forum
Open in Discord
Cape lionOP
16.0.1 with cacheComponents: true is incorrectly requiring Suspense boundaries for useSearchParams() in a fully dynamic route that should not be prerendered. Attempted to wrap the component with useSearchParams() with Suspense boundaries at multiple levels (layout, page, and component), yet the build still fails with:

⨯ Render in Browser should be wrapped in a suspense boundary at page "/auth/partner-signin"

29 Replies

Cape lionOP
Attempting to make a clean reproduction but haven't managed to reproduce yet. My subject repo is complex.
Cape lionOP
@alfonsüs ardani Thanks. The error states the route the error occurs at. /auth/partner-signin
// /auth/partner-signin/page.tsx

import { Suspense } from "react";
import PartnerSignIn from "./PartnerSignIn";

export default function Page() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <PartnerSignIn />
    </Suspense>
  );
}
"use client";

import { Suspense, useEffect, useState } from "react";
import { signIn } from "next-auth/react";
import { useSearchParams, useRouter } from "next/navigation";

export default function PartnerSignIn() {
  const searchParams = useSearchParams();
  const router = useRouter();
  const [status, setStatus] = useState<"loading" | "signing-in" | "error">(
    "loading",
  );

  const token = searchParams.get("token");
  const email = searchParams.get("email");

  useEffect(() => {
    if (!token || !email) {
      queueMicrotask(() => {
        setStatus("error");
      });
      router.push("/login?error=invalid-partner-link");
      return;
    }

    fetch("/api/auth/verify-partner-token", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ token, email }),
    })
      .then((response) => response.json())
      .then(async (data) => {
        if (data.valid) {
          queueMicrotask(() => {
            setStatus("signing-in");
          });
          // Sign in using email provider with pre-verified email
          const result = await signIn("email", {
            email: email,
            redirect: false,
            callbackUrl: data.redirectUrl || "/app?welcome=partner",
          });

          if (result?.url) {
            // Instead of email verification, go directly to the app
            router.push(data.redirectUrl || "/app?welcome=partner");
          } else {
            queueMicrotask(() => {
              setStatus("error");
            });
            router.push("/login?error=signin-failed");
          }
        } else {
          queueMicrotask(() => {
            setStatus("error");
          });
          router.push("/login?error=invalid-token");
        }
      })
      .catch(() => {
        queueMicrotask(() => {
          setStatus("error");
        });
        router.push("/login?error=system-error");
      });
  }, [token, email, router]);

  return ... ;
}
what about:
/auth/partner-signin/layout.jsx
and
/auth/layout.jsx
and
/layout.jsx

?

does any of them have a chance to have used useSearchParams?
Cape lionOP
/auth/layout.tsx
import { Suspense } from "react";

export default function AuthLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>;
}
Root layout
import "@/styles/globals.css";

type LayoutProps = {
  children: React.ReactNode;
};

export default async function RootLayout(props: LayoutProps) {
  const { children } = props;
  return children;
}
@Cape lion "use client"; import { Suspense, useEffect, useState } from "react"; import { signIn } from "next-auth/react"; import { useSearchParams, useRouter } from "next/navigation"; export default function PartnerSignIn() { const searchParams = useSearchParams(); const router = useRouter(); const [status, setStatus] = useState<"loading" | "signing-in" | "error">( "loading", ); const token = searchParams.get("token"); const email = searchParams.get("email"); useEffect(() => { if (!token || !email) { queueMicrotask(() => { setStatus("error"); }); router.push("/login?error=invalid-partner-link"); return; } fetch("/api/auth/verify-partner-token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, email }), }) .then((response) => response.json()) .then(async (data) => { if (data.valid) { queueMicrotask(() => { setStatus("signing-in"); }); // Sign in using email provider with pre-verified email const result = await signIn("email", { email: email, redirect: false, callbackUrl: data.redirectUrl || "/app?welcome=partner", }); if (result?.url) { // Instead of email verification, go directly to the app router.push(data.redirectUrl || "/app?welcome=partner"); } else { queueMicrotask(() => { setStatus("error"); }); router.push("/login?error=signin-failed"); } } else { queueMicrotask(() => { setStatus("error"); }); router.push("/login?error=invalid-token"); } }) .catch(() => { queueMicrotask(() => { setStatus("error"); }); router.push("/login?error=system-error"); }); }, [token, email, router]); return ... ; }
thanks for sending the code, you're awesome
Cape lionOP
There is no /auth/partner-signin/layout.tsx
You're right. this is suspicious
try removing/commenting every other route EXCEPT /auth/partner-signin
Cape lionOP
oof.
I know something weird is going on because I tried to make a repropduction project with just that route and it works.
exactly. its weird. maybe try it in the previous project
hmmm you know, maybe try removing the .next folder
Cape lionOP
ok tried removing all other routes. from CLAUDE

⏺ Excellent! This confirms it's NOT related to generateStaticParams in the [domain] routes.

  The error still occurs with only 4 pages (Generating static pages (0/4)) and just the /auth route active. Notice it now says "Generating static pages (0/4)" instead of "(0/2199)".

  This means:
  - :white_check_mark: The issue is isolated to the /auth/partner-signin route itself
  - :white_check_mark: NOT caused by other routes with generateStaticParams
  - :white_check_mark: The route structure with Suspense boundaries is still failing

  Let me check what those 4 pages are and look more carefully at the /auth route structure:

⏺ Bash(find /Users/alexnelson/Sites/ontempo/app/auth -type f -name "*.tsx" -o -name "*.ts" | sort)
  ⎿  /Users/alexnelson/Sites/ontempo/app/auth/layout.tsx                      
     /Users/alexnelson/Sites/ontempo/app/auth/partner-signin/page.tsx
     /Users/alexnelson/Sites/ontempo/app/auth/partner-signin/PartnerSignIn.tsx

⏺ Bash(ls -R /Users/alexnelson/Sites/ontempo/app/auth)
  ⎿  layout.tsx                   
     partner-signin
     … +3 lines (ctrl+o to expand)

⏺ So only 3 files. Let me look more carefully at the actual Suspense setup. Let me re-read the files to see the current state:

⏺ Read(app/auth/partner-signin/page.tsx)
  ⎿  Read 10 lines

⏺ Read(app/auth/layout.tsx)
  ⎿  Read 10 lines

⏺ Perfect. So we have:
  - Layout with Suspense :white_check_mark:
  - Page with Suspense :white_check_mark:
  - Client component with useSearchParams :white_check_mark:

  This is the exact minimal reproduction. The setup is correct, yet Next.js 16 with cacheComponents: true is still trying to prerender it and failing.
Still getting the error
removing .next
Oh I've been removing .next for debugging. This is my debug script.

rm -rf .next && pnpm next build --debug-prerender
Cape lionOP
I'm stumped!
Cape lionOP
OK it has something to do with proxy.tsx
I reproduced the error
calling getToken() in my middleware is causing the issue

import { NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";

export const config = {
  matcher: [
    "/((?!api/|_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)",
  ],
};

export default async function proxy(req: NextRequest) {
  const url = req.nextUrl;

  // Skip processing for font files
  if (
    url.pathname.includes("/_next/static/fonts/") ||
    url.pathname.match(/\.(ttf|woff|woff2|eot|otf)$/)
  ) {
    return NextResponse.next();
  }

  const hostname = req.headers
    .get("host")!
    .replace(".localhost:3000", `.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`);

  const searchParams = req.nextUrl.searchParams.toString();
  const path = `${url.pathname}${searchParams ? `?${searchParams}` : ""}`;

  // Create base response
  let response: NextResponse;

  // Handle app pages
  if (hostname == `app.${process.env.NEXT_PUBLIC_ROOT_DOMAIN}`) {
    const session = await getToken({ req });

    if (!session && path !== "/login") {
      const loginUrl = new URL("/login", req.url);
      response = NextResponse.redirect(loginUrl);
    } else if (session && path === "/login") {
      const homeUrl = new URL("/", req.url);
      response = NextResponse.redirect(homeUrl);
    } else {
      const appUrl = new URL(`/app${path === "/" ? "" : path}`, req.url);
      response = NextResponse.rewrite(appUrl);
    }
  }
  // Handle root application
  else if (
    hostname === "localhost:3000" ||
    hostname === process.env.NEXT_PUBLIC_ROOT_DOMAIN
  ) {
    const homeUrl = new URL(`/home${path === "/" ? "" : path}`, req.url);
    response = NextResponse.rewrite(homeUrl);
  } else {
    const customUrl = new URL(`/${hostname}${path}`, req.url);
    response = NextResponse.rewrite(customUrl);
  }

  return response;
}
@alfonsüs ardani going to submit an issue. you agree?
Cape lionOP