Skip to main content
All Docs
FeaturesMaking Tax DigitalUpdated March 8, 2026

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:

  1. Uses force-dynamic (opting out of static generation), and
  2. Has no loading.tsx in its route segment, and
  3. 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

RouteMissing FileWhat It Should Show
/dashboardsrc/app/dashboard/loading.tsxSidebar skeleton + content area skeleton
/dashboard/transactionssrc/app/dashboard/transactions/loading.tsxTable skeleton with row placeholders
/dashboard/quarterlysrc/app/dashboard/quarterly/loading.tsxCard 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.tsx is a route-segment convention — Next.js automatically wraps the page in a <Suspense> boundary using the loading.tsx export as the fallback. You do not need to wrap <Page /> manually.
  • force-dynamic + no loading.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-pulse skeleton patterns — use them to keep loading states visually consistent with the rest of the UI.
  • Granularity matters. A single loading.tsx at the /dashboard level 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

MetricBeforeAfter
Time to first visible contentFull server render time (blocking)Near-instant (shell streams immediately)
Perceived load timeHigh — blank screenLow — skeleton visible at once
Streaming SSR utilisedNoYes
Consistent with App Router designNoYes