Web Development
Next.jsReactApp RouterUXStreamingError HandlingPerformance

Next.js App Router UX Patterns: Error Boundaries, Loading UI, and Streaming Done Right

AO
Adrijan Omićević
·15 min read

# Why this matters for production UX#

The App Router makes it easy to ship fast pages, but it also makes it easy to ship fragile UX: one failing fetch can collapse a whole route, loading states can cause layout shifts, and streaming can be misused so users see flicker instead of progress.

This guide shows how to structure route segments and use error.tsx, loading.tsx, not-found.tsx, and Suspense streaming to keep interfaces responsive even when data is slow or broken. You’ll also learn practical patterns for partial rendering and for keeping layout stable.

If you’re migrating from the Pages Router, start with our checklist: Next.js App Router migration checklist. For caching decisions that directly affect perceived UX, see: Next.js caching strategies: SSR, ISR, SWR.

# The mental model: route segments are UX boundaries#

In App Router, every folder in app/ is a route segment. Each segment can have:

  • layout.tsx for shared chrome
  • page.tsx for the leaf content
  • loading.tsx for a segment-level fallback
  • error.tsx for a segment-level error boundary
  • not-found.tsx for segment-level 404 handling

The key UX insight is this: segment boundaries define what can fail, load, and stream independently. If your segment is too broad, users get “all-or-nothing” UI. If it’s well-structured, you get resilient partial rendering.

A practical segment structure for resilient pages#

A common anti-pattern is putting the whole page into a single route segment and then relying on one loading.tsx and one error.tsx. Instead, split by “user-visible sections that can be independent”.

Example for an e-commerce product page:

SectionFailure impactSuggested boundary
Global nav, cart iconMust stay stableRoot layout, outside loading
Product header, priceCriticalSegment with its own error and loading
ReviewsNon-critical, slowNested segment or Suspense boundary
RecommendationsNon-criticalSuspense boundary, optional

A folder layout that supports this:

PathPurposeUX outcome
app/(shop)/layout.tsxstable shop chromeno flicker on navigation
app/(shop)/product/[id]/layout.tsxproduct shellshared skeleton dimensions
app/(shop)/product/[id]/loading.tsxproduct-level skeletonstable reserved space
app/(shop)/product/[id]/error.tsxrecoverable product errorsretry without losing layout
app/(shop)/product/[id]/not-found.tsxproduct not foundcorrect 404 UX
app/(shop)/product/[id]/page.tsxstreams sectionsprogressive rendering

🎯 Key Takeaway: Treat route segments like “blast radius” controls. Smaller, purposeful segments prevent one slow or failing call from blanking the entire page.

# error.tsx: designing failures that users can recover from#

In App Router, error.tsx is a segment-level error boundary. It must be a Client Component, and it catches errors thrown in that segment subtree during rendering, data fetching, and server actions.

A production-ready error.tsx template#

Make your error UI do three things:

  1. 1
    Explain what broke in user language
  2. 2
    Offer a retry
  3. 3
    Capture diagnostics for your observability stack
TSX
'use client';
 
import { useEffect } from 'react';
 
export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // Send to your logging tool (Sentry, Datadog, OpenTelemetry collector, etc.)
    console.error('Route error', { message: error.message, digest: error.digest });
  }, [error]);
 
  return (
    <div style={{ padding: 24 }}>
      <h2>Something went wrong</h2>
      <p>Try again. If the problem persists, contact support.</p>
      <button onClick={() => reset()}>Retry</button>
    </div>
  );
}

The reset() function triggers a re-render of the segment. For transient failures (network hiccups, upstream 502, temporary DB contention), this is often enough.

For deeper instrumentation patterns and what to measure, use our observability guide: Web app observability: logging, metrics, tracing.

Pattern: separate “not found” from “error”#

A common UX bug is showing an error screen for missing content. Use notFound() for expected absence and throw for unexpected failures:

  • Missing product id in DB: call notFound()
  • DB query failed: throw an error
  • Permissions: often notFound() is better than 403 for security through ambiguity, depending on your policy
TSX
import { notFound } from 'next/navigation';
 
async function getProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`, { cache: 'no-store' });
 
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`Failed to load product, status ${res.status}`);
 
  return res.json();
}
 
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const product = await getProduct(id);
 
  if (!product) notFound();
 
  return <div>{product.name}</div>;
}

Pattern: avoid “global” error boundaries for localized failures#

If you put error.tsx too high (for example in app/error.tsx), then any issue inside the app becomes a full-screen failure. That’s sometimes correct for “app is broken” issues, but it’s usually wrong for a single widget or section.

A better approach is layered boundaries:

  • Root error boundary for truly global failures
  • Segment error boundaries for route-level failures
  • Component-level fallbacks using Suspense and local error patterns for non-critical widgets

⚠️ Warning: Don’t rely on one top-level error.tsx to “handle everything”. Users interpret a full-page error as a crash, even if only reviews failed to load.

# not-found.tsx: consistent 404 UX without breaking the app shell#

not-found.tsx is the best way to keep your design consistent for 404 cases. You can scope it per segment, so product pages can show product-friendly messaging, while docs pages show docs-specific navigation.

A segment-scoped not-found.tsx#

TSX
import Link from 'next/link';
 
export default function NotFound() {
  return (
    <div style={{ padding: 24 }}>
      <h2>Product not found</h2>
      <p>Check the link or browse our catalog.</p>
      <Link href="/products">Go to products</Link>
    </div>
  );
}

Pattern: return 404 early to save time and avoid jank#

If you can determine absence quickly (for example via a lightweight head request, index lookup, or a cached metadata endpoint), call notFound() early. It prevents downstream UI from rendering and reduces wasted work.

This also improves Core Web Vitals because you don’t paint a “loading” UI only to replace it with a 404 page.

# loading.tsx: segment-level loading UI that avoids layout shifts#

loading.tsx renders while the segment is loading during navigation and initial load. It’s your best tool for preventing “blank screens” and for keeping layout stable.

What good loading UI does#

Good loading UI:

  • Keeps page structure consistent
  • Reserves space so content doesn’t jump
  • Shows progress without distracting animation
  • Avoids mismatched typography and spacing

Bad loading UI:

  • Uses a generic spinner centered on the page
  • Removes header and nav, then re-adds them
  • Renders skeleton sizes that don’t match the final layout
  • Changes the number of list rows between loading and loaded state

Example loading.tsx with reserved space#

This skeleton reserves space for a title, image, and primary actions. It’s intentionally “blocky” to match real dimensions.

TSX
export default function Loading() {
  return (
    <div style={{ padding: 24 }}>
      <div style={{ height: 32, width: '60%', background: '#eee', borderRadius: 8 }} />
      <div style={{ height: 16, width: '40%', background: '#eee', borderRadius: 8, marginTop: 12 }} />
      <div style={{ height: 360, width: '100%', background: '#eee', borderRadius: 12, marginTop: 20 }} />
      <div style={{ display: 'flex', gap: 12, marginTop: 16 }}>
        <div style={{ height: 44, width: 160, background: '#eee', borderRadius: 10 }} />
        <div style={{ height: 44, width: 120, background: '#eee', borderRadius: 10 }} />
      </div>
    </div>
  );
}

Pattern: keep stable chrome outside the loading boundary#

If your header, sidebar, or breadcrumbs are inside the segment that’s loading, they will disappear and reappear on navigation. Move stable chrome into a parent layout.

A practical layout layering:

  • app/layout.tsx: global header, theme, analytics
  • app/(shop)/layout.tsx: shop nav, category sidebar
  • app/(shop)/product/[id]/layout.tsx: product page shell
  • app/(shop)/product/[id]/page.tsx: content that streams

💡 Tip: Design skeletons from your actual UI components. Take the final component layout and replace content with blocks of the same size. This is the most reliable way to minimize CLS.

Layout shift controls that actually work#

CLS is mostly about unexpected size changes after paint. For App Router pages, the biggest culprits are images, async content blocks, and conditional toolbars.

Use these controls:

CLS sourceFixPractical example
ImagesReserve aspect ratioUse consistent card media height and avoid “auto” heights
ListsKeep row count stableShow 8 skeleton rows if you usually show 8 items above the fold
FontsUse stable font loadingPreload fonts, use font-display: swap only if layout won’t shift
Conditional UIReserve toolbar spaceRender an empty toolbar container that later fills in

# Streaming with Suspense: partial rendering without flicker#

Streaming is where App Router shines: you can render the shell immediately, then progressively reveal slower sections. The goal is not “everything streams”, but “the right things stream”.

The core pattern: fast shell, slow widgets#

A typical product page has:

  • fast: name, hero image, buy button
  • slow: reviews, related products, inventory by warehouse, personalization

Make your shell render with the critical data first, then stream the rest using Suspense boundaries.

TSX
import { Suspense } from 'react';
 
function ReviewsFallback() {
  return <div style={{ height: 220, background: '#eee', borderRadius: 12 }} />;
}
 
function RecosFallback() {
  return <div style={{ height: 180, background: '#eee', borderRadius: 12 }} />;
}
 
export default async function Page() {
  return (
    <div style={{ padding: 24 }}>
      <h1>Product title</h1>
 
      <Suspense fallback={<ReviewsFallback />}>
        {/* Server Component that fetches slower data */}
        {/* @ts-expect-error Async Server Component */}
        <Reviews />
      </Suspense>
 
      <Suspense fallback={<RecosFallback />}>
        {/* @ts-expect-error Async Server Component */}
        <Recommendations />
      </Suspense>
    </div>
  );
}

This keeps the page interactive earlier and avoids blocking on non-critical calls.

Pattern: make “slow” explicit with a small artificial delay during development#

Teams often think they’re streaming, but in local dev everything is too fast to notice issues. Add a dev-only delay in slow components so you can see real behavior.

TypeScript
export async function sleep(ms: number) {
  if (process.env.NODE_ENV !== 'development') return;
  await new Promise((r) => setTimeout(r, ms));
}

Use it in a server component fetch path so you can verify that skeleton dimensions match and that no layout jumps.

Pattern: partial failure handling inside a streamed section#

A big win with streaming is that you can isolate failure. If reviews fail, the rest of the product page should still work.

You can do this with a nested route segment containing its own error.tsx, or with local error UI depending on your structure. For route-level isolation, a nested segment is straightforward.

Example structure:

PathWhat it isolates
app/(shop)/product/[id]/page.tsxproduct shell
app/(shop)/product/[id]/reviews/page.tsxreviews section
app/(shop)/product/[id]/reviews/error.tsxreviews-only error UI
app/(shop)/product/[id]/reviews/loading.tsxreviews skeleton

Then render reviews via a parallel slot or by linking to a nested route pattern that fits your app. The key is isolation: the reviews subtree owns its loading and error states.

ℹ️ Note: If you don’t want URL changes or nested routing complexity, you can still isolate failures by keeping reviews in a separate Suspense boundary and letting the error be caught by the nearest segment error.tsx. The tradeoff is the error boundary scope.

# Data fetching failure patterns that don’t tank UX#

Most production issues are not “code exceptions”. They’re upstream timeouts, rate limits, empty states, and partial data.

Pattern: classify fetch results and render appropriate UI#

Instead of treating every non-200 as “throw error”, classify:

  • 404: call notFound() for the resource route
  • 401 and 403: show an auth or access UI, or redirect
  • 429: show a retry later message
  • 500 and network failures: throw to error.tsx if the section is critical, otherwise render a degraded UI

Keep the logic centralized.

TypeScript
export type FetchResult<T> =
  | { ok: true; data: T }
  | { ok: false; status: number; message: string };
 
export async function safeJson<T>(url: string): Promise<FetchResult<T>> {
  try {
    const res = await fetch(url, { next: { revalidate: 60 } });
    if (!res.ok) return { ok: false, status: res.status, message: res.statusText };
    return { ok: true, data: (await res.json()) as T };
  } catch (e) {
    return { ok: false, status: 0, message: e instanceof Error ? e.message : 'Unknown error' };
  }
}

Now the component decides how harsh the fallback should be.

Pattern: degrade non-critical sections instead of throwing#

For recommendations, it’s usually better to show nothing than to show a scary error. That keeps users focused on the primary action.

TSX
import { safeJson } from '@/lib/safeJson';
 
type Reco = { id: string; name: string };
 
export async function Recommendations() {
  const result = await safeJson<Reco[]>('https://api.example.com/recommendations');
 
  if (!result.ok) {
    return (
      <section>
        <h3>Recommended for you</h3>
        <p style={{ opacity: 0.7 }}>Recommendations are unavailable right now.</p>
      </section>
    );
  }
 
  return (
    <section>
      <h3>Recommended for you</h3>
      <ul>
        {result.data.slice(0, 6).map((r) => (
          <li key={r.id}>{r.name}</li>
        ))}
      </ul>
    </section>
  );
}

For the critical product details, you generally want to throw so error.tsx can provide a consistent “try again” path.

Pattern: align caching strategy with loading UX#

Caching changes whether users see loading.tsx often or rarely.

A quick decision table:

GoalRecommended approachUX impact
Always fresh datacache: 'no-store'more loading states, more streaming needed
Fast repeat viewsnext.revalidate with sensible TTLfewer spinners, consistent UX
Personalized sectionsper-user fetch, often uncachedisolate into Suspense to avoid blocking

If you need a deeper framework for this, use: Next.js caching strategies: SSR, ISR, SWR.

# Route segment recipes you can copy#

These are “default good” structures we implement for clients when reliability matters.

Recipe 1: dashboard with independent widgets#

Goal: the shell loads instantly, widgets stream in, and one widget failing doesn’t blank the whole dashboard.

Suggested structure:

FilePurpose
app/(app)/dashboard/layout.tsxdashboard chrome, navigation
app/(app)/dashboard/loading.tsxminimal scaffold
app/(app)/dashboard/error.tsxonly for shell-breaking errors
app/(app)/dashboard/page.tsxstreams widgets with Suspense
app/(app)/dashboard/widgets/*optional: nested segments per widget

In page.tsx, keep stable layout grid even during loading. Skeleton blocks should match widget sizes.

Recipe 2: content site with strong 404 behavior#

Goal: 404 pages are branded, segment-specific, and avoid confusion.

Suggested structure:

Segmentnot-found.tsx content
app/(docs)docs search and sidebar links
app/(blog)latest posts and categories
app/(marketing)CTA to main product

This prevents the “generic 404” that drops users into a dead end.

Recipe 3: product detail with streaming and isolated errors#

Goal: product details must be reliable; reviews and recommendations must not affect conversion.

Suggested boundaries:

  • Product details: segment boundary with error.tsx and loading.tsx
  • Reviews: separate Suspense boundary, optionally its own nested segment
  • Recommendations: degrade gracefully, avoid throwing

# Testing and observability: verify UX under real failure modes#

You don’t really have a resilient UX until you’ve tested:

  • slow network
  • upstream 500
  • timeouts
  • partial 404
  • repeated navigations

A simple failure injection checklist#

Use this as a pre-release gate:

ScenarioExpected UXWhere it’s implemented
Product API returns 404show product not-found.tsxnotFound() + not-found.tsx
Product API returns 500show segment error with retryerror.tsx
Reviews API times outshow reviews fallback or degraded UISuspense or nested segment
Navigation between productsstable header, no jumpsparent layout + reserved skeleton

Instrument retries and fallback frequency#

If error.tsx retry is used frequently, you have an upstream reliability problem or overly aggressive caching invalidation. Track:

  • error boundary render count per route
  • retry click rate
  • time to first byte and time to first contentful paint on slow routes
  • the percentage of sessions seeing loading.tsx for key journeys

For implementation patterns and what metrics matter, use: Web app observability: logging, metrics, tracing.

# Key Takeaways#

  • Structure route segments so each user-visible section has a controlled blast radius, instead of one giant segment that fails as a whole.
  • Use notFound() and not-found.tsx for expected absence, and throw errors for unexpected failures to activate error.tsx with a retry path.
  • Use loading.tsx to prevent blank screens and reduce CLS by reserving space with skeletons that match final UI dimensions.
  • Stream non-critical sections with Suspense so users get a fast shell and progressive disclosure of slower widgets.
  • Degrade gracefully for non-critical data failures, and reserve full-screen errors for truly blocking issues.

# Conclusion#

A resilient App Router UX is mostly about boundaries: route segments define what can load, fail, and stream independently. When you combine segment-scoped loading.tsx, error.tsx, and not-found.tsx with targeted Suspense boundaries, you get faster perceived performance, fewer layout shifts, and fewer “the app is broken” moments.

If you want us to review your current App Router structure and implement resilient loading and error patterns end-to-end, contact Samioda. We’ll help you design segment boundaries, streaming strategy, and observability so your UX stays stable even when real-world systems misbehave.

FAQ

Share
A
Adrijan OmićevićFounder & Senior Developer

Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.

Need help with your project?

We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.