Streaming is one of the genuinely great things about the App Router, the browser can start receiving and rendering parts of a page before every single piece of data has finished loading. It also introduces a real ordering question that's easy to get wrong when auth checks and Suspense boundaries interact, and getting it wrong doesn't throw an error, it just occasionally shows a logged-out visitor a flash of something meant only for an authenticated user.
The Setup That Looks Fine
// app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex">
<Sidebar /> {/* renders immediately, not wrapped in Suspense */}
<main>{children}</main>
</div>
);
}
// app/dashboard/page.tsx
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<AuthenticatedContent />
</Suspense>
);
}
async function AuthenticatedContent() {
const session = await getSession();
if (!session) {
redirect('/login'); // this runs, eventually
}
const data = await getDashboardData();
return <Dashboard data={data} />;
}
This looks reasonable. Unauthenticated users get redirected to login. The problem is entirely about timing, not logic.
What Actually Happens, in Order
Streaming means the layout, Sidebar included, is not waiting on AuthenticatedContent to resolve before it gets sent to the browser. Sidebar has no dependency on the auth check, so React starts streaming it immediately, along with the DashboardSkeleton fallback in place of the still-loading AuthenticatedContent. Only after getSession() resolves and comes back empty does redirect('/login') actually fire, at which point the browser navigates away.
For a fast auth check, this gap is often imperceptible. For a slower one, a cold database connection, a slightly slow session lookup, there's a real window where an unauthenticated visitor sees the dashboard's sidebar, navigation, and skeleton loading state, genuine UI structure meant only for logged-in users, before the redirect actually kicks in and sends them away.
Why This Matters More Than It Sounds Like It Should
For most dashboards, a brief flash of sidebar navigation before a redirect isn't a severe security issue, the actual data never streamed, AuthenticatedContent never resolved with real information for an unauthorized visitor. But it does leak structural information, what sections exist, what the navigation looks like, sometimes feature names or areas that were meant to stay unannounced until launch. For anything more sensitive than that, a scenario where the Suspense-wrapped content itself has multiple nested pieces, some of which don't actually depend on the same auth check, the risk of a genuinely sensitive fragment slipping through before redirect increases significantly.
The Actual Fix: Check Auth Before the Suspense Boundary, Not Inside It
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/auth';
export default async function DashboardPage() {
const session = await getSession(); // resolved BEFORE anything streams
if (!session) {
redirect('/login');
}
return (
<Suspense fallback={<DashboardSkeleton />}>
<DashboardContent userId={session.userId} />
</Suspense>
);
}
Moving the auth check above and outside the Suspense boundary means it resolves before React starts streaming anything from this route at all. Nothing from DashboardPage, or the content it would have rendered, reaches the browser until the redirect decision has already been made. The Suspense boundary now only wraps genuinely non-sensitive loading, the dashboard's own data fetching, not the authorization decision itself.
The Broader Rule
Authorization checks belong outside and above any Suspense boundary that gates access to something. Once a check is inside a boundary that's already streaming its fallback to the browser, you've already started sending something, and a redirect afterward is a correction, not a prevention. The layout wrapping a protected route should generally also perform its own check, since a layout renders before its children and won't itself accidentally stream protected content while waiting on an inner boundary to resolve.
// app/dashboard/layout.tsx
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await getSession(); // checked here too, before ANY streaming starts
if (!session) redirect('/login');
return (
<div className="flex">
<Sidebar role={session.role} />
<main>{children}</main>
</div>
);
}
Checking at the layout level closes the gap entirely for that route and everything nested under it, since nothing in the tree streams until this resolves.
Go Check Your Own Protected Routes
Specifically look for any redirect() call that lives inside a component wrapped in Suspense, rather than in the layout or page itself, before any Suspense boundary is reached. If your auth check is the thing inside the boundary rather than the thing gating the boundary, that's this exact pattern.
Curious whether this timing gap was something people had already run into, or whether it's as much of a surprise as it was for me the first time I actually watched it happen on a slow connection during testing. Drop your experience in the comments.
Get the templates: https://pixelanas.gumroad.com
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)