# 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.tsxfor shared chromepage.tsxfor the leaf contentloading.tsxfor a segment-level fallbackerror.tsxfor a segment-level error boundarynot-found.tsxfor 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:
| Section | Failure impact | Suggested boundary |
|---|---|---|
| Global nav, cart icon | Must stay stable | Root layout, outside loading |
| Product header, price | Critical | Segment with its own error and loading |
| Reviews | Non-critical, slow | Nested segment or Suspense boundary |
| Recommendations | Non-critical | Suspense boundary, optional |
A folder layout that supports this:
| Path | Purpose | UX outcome |
|---|---|---|
app/(shop)/layout.tsx | stable shop chrome | no flicker on navigation |
app/(shop)/product/[id]/layout.tsx | product shell | shared skeleton dimensions |
app/(shop)/product/[id]/loading.tsx | product-level skeleton | stable reserved space |
app/(shop)/product/[id]/error.tsx | recoverable product errors | retry without losing layout |
app/(shop)/product/[id]/not-found.tsx | product not found | correct 404 UX |
app/(shop)/product/[id]/page.tsx | streams sections | progressive 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:
- 1Explain what broke in user language
- 2Offer a retry
- 3Capture diagnostics for your observability stack
'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
idin DB: callnotFound() - DB query failed: throw an error
- Permissions: often
notFound()is better than403for security through ambiguity, depending on your policy
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.tsxto “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#
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.
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, analyticsapp/(shop)/layout.tsx: shop nav, category sidebarapp/(shop)/product/[id]/layout.tsx: product page shellapp/(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 source | Fix | Practical example |
|---|---|---|
| Images | Reserve aspect ratio | Use consistent card media height and avoid “auto” heights |
| Lists | Keep row count stable | Show 8 skeleton rows if you usually show 8 items above the fold |
| Fonts | Use stable font loading | Preload fonts, use font-display: swap only if layout won’t shift |
| Conditional UI | Reserve toolbar space | Render 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.
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.
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:
| Path | What it isolates |
|---|---|
app/(shop)/product/[id]/page.tsx | product shell |
app/(shop)/product/[id]/reviews/page.tsx | reviews section |
app/(shop)/product/[id]/reviews/error.tsx | reviews-only error UI |
app/(shop)/product/[id]/reviews/loading.tsx | reviews 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.tsxif the section is critical, otherwise render a degraded UI
Keep the logic centralized.
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.
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:
| Goal | Recommended approach | UX impact |
|---|---|---|
| Always fresh data | cache: 'no-store' | more loading states, more streaming needed |
| Fast repeat views | next.revalidate with sensible TTL | fewer spinners, consistent UX |
| Personalized sections | per-user fetch, often uncached | isolate 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:
| File | Purpose |
|---|---|
app/(app)/dashboard/layout.tsx | dashboard chrome, navigation |
app/(app)/dashboard/loading.tsx | minimal scaffold |
app/(app)/dashboard/error.tsx | only for shell-breaking errors |
app/(app)/dashboard/page.tsx | streams 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:
| Segment | not-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.tsxandloading.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:
| Scenario | Expected UX | Where it’s implemented |
|---|---|---|
| Product API returns 404 | show product not-found.tsx | notFound() + not-found.tsx |
| Product API returns 500 | show segment error with retry | error.tsx |
| Reviews API times out | show reviews fallback or degraded UI | Suspense or nested segment |
| Navigation between products | stable header, no jumps | parent 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.tsxfor 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()andnot-found.tsxfor expected absence, and throw errors for unexpected failures to activateerror.tsxwith a retry path. - Use
loading.tsxto 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
Founder & Senior Developer at Samioda. 8+ years building React, Next.js, Flutter and n8n automation solutions for clients across Europe.
More in Web Development
All →Building a Design System in Next.js with Radix UI, Tailwind, and Storybook: End-to-End Guide for 2026
A practical, production-ready approach to building and maintaining a Next.js design system using Radix UI for accessibility, Tailwind for styling, and Storybook for documentation, testing, and versioned releases.
The React Code Review Checklist We Use: Performance, Accessibility, and Maintainability
A practical React code review checklist focused on performance, accessibility, and maintainability, with examples, automation tips, and a copy-paste template.
Next.js Multi‑Region Deployments: Patterns for Lower Latency on Vercel and Cloudflare
A practical guide to Next.js multi-region deployment in 2026: edge rendering, regional SSR, and data locality patterns on Vercel and Cloudflare, including database caveats and a latency validation checklist.
Need help with your project?
We build custom solutions using the technologies discussed in this article. Senior team, fixed prices.
Related Articles
React Query at Scale: Cache Invalidation, Pagination, and Mutation Patterns for Real Apps
React Query cache invalidation best practices for real-world apps: scalable query key design, invalidation strategy, optimistic updates, infinite queries, and background refetching in Next.js App Router.
Next.js Caching Strategies Explained: SSR, SSG, ISR, Route Cache, and SWR
A practical guide to Next.js caching strategies in the App Router era — how SSR, SSG, ISR, the Route Cache, Data Cache, and SWR fit together, with decision tables, code examples, and common pitfalls like stale auth and tenant data.
Next.js App Router Migration Checklist (From Pages Router) + Common Pitfalls
A practical, step-by-step Next.js App Router migration plan from Pages Router, including a checklist for routing, data fetching, SEO metadata, deployment, and a troubleshooting guide for common pitfalls.