loading.js and error.js in Next.js: Clean Loading and Error States
By Arash Latifi
Practical guide to loading.js and error.js in the Next.js App Router with Suspense and streaming: skeleton UIs, error boundaries, and retry buttons.
TL;DR — Drop a
loading.jsnext to any page for an automatic skeleton and anerror.jsclient component with aresetretry button; wrap slow sections inSuspensefor finer control.
The core idea
In the App Router, a loading.js file next to any page.js renders automatically while data loads, and an error.js file shows a proper message with a retry button instead of a white screen when something breaks. No manual setTimeout, no isLoading flags scattered across components.
One catch: loading.js only kicks in when the page is genuinely async (a fetch or dynamic data). Static pages render instantly, so no loading state appears — that's expected.
Skeleton loading with loading.js
A skeleton that mimics the real page shape; users feel progress instead of a freeze:
To avoid blocking the whole page, wrap only the slow part in Suspense so the rest renders immediately (streaming). This pairs nicely with image optimization since images are usually the slowest part:
Graceful errors with error.js
The error file must be a client component ("use client") receiving two props: the error itself and a reset function for retrying:
Don't forget to log errors (Sentry or whatever you use). error.js only catches its nearest parent segment, so header and footer stay in place while the middle shows the message. A crash in the root layout needs global-error.js instead.
Quick comparison
| Approach | Good for | Bad for |
| --- | --- | --- |
| loading.js | Whole-segment loading, zero extra code | Blocks the whole segment |
| Suspense around slow parts | Real streaming, better UX | Slightly more code |
| error.js + reset | Scoped errors plus retry | Only render/data errors in that segment |
| Manual try/catch | Per-action custom logic | Repeated boilerplate everywhere |
Rule of thumb: loading.js to start, Suspense for key sales pages, error.js everywhere.
Checklist
- [x] Every data-driven route has a
loading.jsshaped like the page - [x]
error.jsis a client component with aresetretry button - [x] Errors get logged, not just displayed
- [ ] Slow sections of key pages split with
Suspense(e.g. product lists) - [ ] Error behavior tested manually once (e.g. kill the API)
- [ ] If you have auth, cross-check with the middleware auth guide so login redirects don't clash with error boundaries
FAQ
Why doesn't my loading.js show?
Because your page renders statically. Without a dynamic fetch or a real await, the page is ready instantly and there's nothing to show. Test with Suspense around an async component.
What exactly does reset do?
It re-renders the failed segment without a full page refresh. Transient failures (API hiccup) recover; permanent ones show the error again.
What doesn't error.js catch?
Root-layout errors and server errors outside rendering (like middleware). Use global-error.js for the former and handle the latter separately.