PERF-07: Fixing Blank Screens with Suspense Boundaries and loading.tsx
PERF-07: Fixing Blank Screens with Suspense Boundaries and loading.tsx
Release: v1.0.330 · Performance · Frontend
Overview
Next.js App Router's streaming SSR is one of its most significant performance features — it lets the server flush the page shell to the browser immediately, so users see a skeleton UI while slower data-fetching completes in the background. However, this only works if loading.tsx files and <Suspense> boundaries are in place.
As of v1.0.330, a performance audit (PERF-07) identified that none of the dashboard routes have a loading.tsx file, and DashboardShell returns null during auth loading. Because all pages also use force-dynamic, every navigation triggers a full blocking server render with nothing shown to the user until it completes.
Why This Matters
The Blank Screen Problem
When a Next.js App Router page:
- Uses
force-dynamic(opting out of static generation), and - Has no
loading.tsxin its route segment, and - Has no
<Suspense>boundaries around async components
…the browser receives nothing until the entire server render finishes — including auth verification, database queries, and any external API calls (e.g. HMRC OAuth checks). On slow connections or under load, this can mean several seconds of blank white screen.
How App Router Streaming Is Supposed to Work
Request arrives
│
▼
Next.js flushes loading.tsx shell instantly ──▶ Browser shows skeleton UI
│
▼
Server renders async RSC components
│
▼
Streamed HTML chunks sent as each Suspense boundary resolves
│
▼
Full page hydrated progressively
Without loading.tsx, step one never happens. The browser waits at the request stage until everything is done.
Affected Routes
| Route | Missing File | What It Should Show |
|---|---|---|
/dashboard | src/app/dashboard/loading.tsx | Sidebar skeleton + content area skeleton |
/dashboard/transactions | src/app/dashboard/transactions/loading.tsx | Table skeleton with row placeholders |
/dashboard/quarterly | src/app/dashboard/quarterly/loading.tsx | Card skeletons for quarterly summary tiles |
Recommended Implementation
1. Add loading.tsx to Dashboard Routes
Use the animate-pulse skeleton pattern already present in the codebase for visual consistency.
// src/app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="flex h-screen animate-pulse">
{/* Sidebar skeleton */}
<div className="h-full w-64 bg-muted rounded-r-lg" />
{/* Main content skeleton */}
<div className="flex-1 space-y-4 p-6">
<div className="h-8 w-56 bg-muted rounded" />
<div className="grid grid-cols-3 gap-4">
<div className="h-28 bg-muted rounded" />
<div className="h-28 bg-muted rounded" />
<div className="h-28 bg-muted rounded" />
</div>
<div className="h-64 bg-muted rounded" />
</div>
</div>
);
}
// src/app/dashboard/transactions/loading.tsx
export default function TransactionsLoading() {
return (
<div className="space-y-2 animate-pulse p-6">
<div className="h-8 w-48 bg-muted rounded" />
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-12 bg-muted rounded" />
))}
</div>
);
}
// src/app/dashboard/quarterly/loading.tsx
export default function QuarterlyLoading() {
return (
<div className="space-y-4 animate-pulse p-6">
<div className="h-8 w-64 bg-muted rounded" />
<div className="grid grid-cols-2 gap-4">
<div className="h-40 bg-muted rounded" />
<div className="h-40 bg-muted rounded" />
</div>
</div>
);
}
2. Wrap Async RSC Data Fetching with <Suspense>
For finer-grained streaming within a page, wrap individual async Server Components in <Suspense> boundaries. This lets the page shell render immediately while each data-dependent section streams in independently.
// src/app/dashboard/transactions/page.tsx
import { Suspense } from 'react';
import { TransactionTableSkeleton } from '@/components/skeletons';
import { TransactionTable } from '@/components/transactions/transaction-table';
export const dynamic = 'force-dynamic';
export default function TransactionsPage() {
return (
<div>
<h1>Transactions</h1>
<Suspense fallback={<TransactionTableSkeleton />}>
{/* TransactionTable is an async RSC that fetches data */}
<TransactionTable />
</Suspense>
</div>
);
}
3. Avoid Returning null During Auth Loading
The DashboardShell component currently returns null while authentication state is being resolved. This should instead render the skeleton layout, allowing the shell chrome (sidebar, header) to remain visible while auth resolves.
// Before
if (authLoading) return null;
// After
if (authLoading) return <DashboardShellSkeleton />;
Key Principles
loading.tsxis a route-segment convention — Next.js automatically wraps the page in a<Suspense>boundary using theloading.tsxexport as the fallback. You do not need to wrap<Page />manually.force-dynamic+ noloading.tsx= always blocking. If a route opts out of static rendering, it must have a loading state or it will always produce a blank screen during render.- Reuse existing skeleton components. The codebase already has
animate-pulseskeleton patterns — use them to keep loading states visually consistent with the rest of the UI. - Granularity matters. A single
loading.tsxat the/dashboardlevel covers the whole segment, but per-component<Suspense>boundaries let different parts of the page load independently, which is better for pages with multiple independent data sources (e.g. transaction list, quarterly summary, HMRC connection status).
Expected Outcome
| Metric | Before | After |
|---|---|---|
| Time to first visible content | Full server render time (blocking) | Near-instant (shell streams immediately) |
| Perceived load time | High — blank screen | Low — skeleton visible at once |
| Streaming SSR utilised | No | Yes |
| Consistent with App Router design | No | Yes |