Next.js 13+ introduced the App Router, React Server Components, and a more powerful routing model. With these improvements came a new, intuitive way to handle loading and error states directly at the route level. For beginners, this is one of the most valuable features because it eliminates boilerplate and lets you focus on building great UX.
Understanding Loading & Error States in the App Router
In traditional React, you manually handle loading and errors inside components like this:
if (isLoading) return <Spinner />
if (error) return <ErrorComponent />
But with Server Components, data is fetched before rendering the UI. That means loading and error states occur at the route level, not inside individual components.
Next.js solves this with two special files:
- loading.js: Automatically shown while the route's Server Components are being fetched
- error.js: Automatically displayed when the route throws an error
Both files sit next to the page and control the UX for that route.
Basic File Structure
app/
+-- dashboard/
+-- page.js
+-- loading.js
+-- error.js
When /dashboard loads:
- loading.js appears first
- page.js appears when data is ready
- If something goes wrong, error.js appears
1. Handling Loading States with loading.js
When your Server Component is waiting for data (database, API, CMS, etc.), Next.js automatically shows loading.js.
Create a loading.js file:
// app/dashboard/loading.js
export default function Loading() {
return (
<div className="flex items-center justify-center h-[60vh]">
<p className="text-lg font-medium">Loading dashboard...</p>
</div>
);
}
Example Server Component with async fetch
// app/dashboard/page.js
export default async function DashboardPage() {
const response = await fetch("https://jsonplaceholder.typicode.com/users", {
cache: "no-store",
});
const users = await response.json();
return (
<div>
<h1 className="text-2xl font-bold mb-4">Users</h1>
<ul>
{users.map((user) => (
<li key={user.id} className="py-1 border-b">
{user.name}
</li>
))}
</ul>
</div>
);
}
When this page starts loading, loading.js displays.
2. Handling Error States with error.js
If your Server Component throws an error (network failure, DB issue, code bug), Next.js will automatically render error.js.
Create an error.js file:
// app/dashboard/error.js
"use client";
export default function Error({ error, reset }) {
return (
<div className="p-6">
<h2 className="text-xl font-semibold text-red-600">
Something went wrong!
</h2>
<p className="mt-2 text-gray-700">{error.message}</p>
<button
onClick={reset}
className="mt-4 bg-blue-600 text-white px-4 py-2 rounded"
>
Try Again
</button>
</div>
);
}
reset() re-renders the Server Component, useful when fetching external data. Make sure to add "use client", error.js must be a Client Component because it handles button clicks.
3. Throwing Errors Manually in Server Components
You can throw errors directly inside your Server Component:
// app/dashboard/page.js
export default async function DashboardPage() {
const res = await fetch("https://api.example.com/data");
if (!res.ok) {
throw new Error("Failed to fetch dashboard data");
}
const data = await res.json();
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
The above error automatically shows your route’s error.js.
4. Using Suspense for Component-Level Loading
If you want loading states inside your page, not just route level. Next.js supports React Suspense even for Server Components.
Example
// app/dashboard/page.js
import { Suspense } from "react";
import UserList from "./user-list";
export default function Dashboard() {
return (
<Suspense fallback={<p>Loading users...</p>}>
<UserList />
</Suspense>
);
}
user-list.js (Server Component)
// app/dashboard/user-list.js
export default async function UserList() {
const res = await fetch("https://jsonplaceholder.typicode.com/users");
const users = await res.json();
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
5. Best Practices for Production
- Keep loading.js lightweight
Avoid heavy animations or expensive computations. Users should see it instantly.
- Do not expose sensitive error messages
In production, throw generic errors:
throw new Error("Unable to load user data"); - Use cache: "no-store" for dynamic data
Prevents stale responses.
- Prefer Suspense for partially loading pages
Pages feel faster when some sections load independently.
- Log server errors
You can integrate logging:
if (!res.ok) {
console.error("API error:", res.statusText);
throw new Error("Failed to fetch data");
}Conclusion
Next.js Server Components make loading and error handling cleaner than ever. Instead of juggling state variables inside components, you let Next.js manage it at the routing level using loading.js and error.js.
To read more about A Complete Beginner’s Guide to Getting Started with Next.js, refer to our blog A Complete Beginner’s Guide to Getting Started with Next.js.