C
Next.js/Basics/Lesson 03

File Conventions — loading.tsx · error.tsx · not-found.tsx

30 min·theory
This chapter
3/3
TypeScript

File Conventions — loading.tsx · error.tsx · not-found.tsx

💡 Why Learn This? — 'File Name = Behavior' Convention

🎯 In the Pages Router era: you had to hand-craft a `` component and import it into every page for conditional rendering.
💼 With the App Router: just place a `loading.tsx` file in the same folder and you're done. Next.js automatically wraps the route in `}>`.
Error handling works the same way — add an `error.tsx` file and any errors thrown within that folder's subtree are automatically caught and shown as a fallback UI.
🔗 Without knowing this, you'll fall into debugging traps like 'Why does the loading state appear automatically?' or 'I have no idea where the error is being caught.'
📈 In nested routes, the closest file takes effect — the folder tree is the UI tree.
🏢 실무에서는
While a Server Component awaits data via `fetch`, users see a blank screen. Place a single `loading.tsx` in the same folder and a skeleton UI is automatically shown during that wait. If the fetch throws, `error.tsx` automatically catches it and displays a 'Try again' button. All of this requires zero additional lines of code.

5 Special Files — The Name Is the Convention

1. The 5 Files and Their Roles

FileWhat It Automatically AppliesComponent Type
page.tsxThe main body of a URL route's pageServer or Client
layout.tsxShared layout for all pages under that folder (state preserved)Server or Client
loading.tsx<Suspense> fallback while the folder's page is loadingServer or Client
error.tsxReact Error Boundary fallback when an error occurs in that folderClient only ('use client' required)
not-found.tsxRendered when notFound() is called or no route matchesServer or Client

2. loading.tsx — Automatic Suspense Wrapping

tsx
// app/posts/loading.tsx
export default function Loading() {
  return <div className="animate-pulse">Loading...</div>;
}
tsx
// app/posts/page.tsx — while this component awaits
export default async function PostsPage() {
  const posts = await fetch('https://api/posts').then(r => r.json());
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}
// → loading.tsx is automatically shown as the fallback
//   Equivalent to <Suspense fallback={<Loading />}><PostsPage /></Suspense>

3. error.tsx — Automatic Error Boundary

tsx
// app/posts/error.tsx
'use client'; // ★ error boundaries require Client

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <h2>An error occurred</h2>
      <p>{error.message}</p>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}
  • Calling reset() resets the Error Boundary and re-attempts rendering the page.
  • error.digest is a server-side error identifier (for logging).
  • error.tsx cannot catch errors thrown by layout.tsx in the same folder — place it one directory level up.

4. not-found.tsx — 404 UI

tsx
// app/posts/[id]/page.tsx
import { notFound } from 'next/navigation';

export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await db.post.findUnique({ where: { id: Number(params.id) } });
  if (!post) notFound(); // calling this renders not-found.tsx
  return <article>{post.title}</article>;
}
tsx
// app/posts/[id]/not-found.tsx
export default function NotFound() {
  return <div>The post you are looking for does not exist. <Link href="/posts">Back to list</Link></div>;
}

5. Nesting — The Nearest File Takes Effect

code
app/
├── error.tsx          ← global fallback
├── posts/
│   ├── error.tsx      ← catches /posts/* errors
│   ├── loading.tsx    ← shown during /posts/* loading
│   └── [id]/
│       └── error.tsx  ← nearest to /posts/[id] → catches those errors

The nearest file takes priority. If no file exists in the same folder, Next.js walks up to the parent folder.

💻 🅰️ Pages Router — Manual Suspense + Error Boundary
// ❌ Pages Router — Manually create components + conditional rendering

// 📁 components/Loading.tsx
export function Loading() {
  return <div className="animate-pulse">Loading...</div>;
}

// 📁 components/ErrorBoundary.tsx
import { Component, type ReactNode } from 'react';

class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
  state = { error: null as Error | null };
  static getDerivedStateFromError(error: Error) { return { error }; }
  render() {
    if (this.state.error) {
      return (
        <div>
          <h2>Error: {this.state.error.message}</h2>
          <button onClick={() => this.setState({ error: null })}>Retry</button>
        </div>
      );
    }
    return this.props.children;
  }
}
export { ErrorBoundary };

// 📁 pages/posts/index.tsx
import { useState, useEffect } from 'react';
import { Loading } from '../../components/Loading';
import { ErrorBoundary } from '../../components/ErrorBoundary';

export default function PostsPage() {
  const [posts, setPosts] = useState(null);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    fetch('/api/posts')
      .then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
      .then(setPosts)
      .catch(setError);
  }, []);

  if (error) return <div>Error: {error.message}</div>;
  if (!posts) return <Loading />;
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

// 📁 pages/_error.tsx — Global error (Pages Router only)
function Error({ statusCode }: { statusCode: number }) {
  return <p>{statusCode ? `Server ${statusCode} error` : 'Client error'}</p>;
}
Error.getInitialProps = ({ res, err }) => {
  const statusCode = res?.statusCode ?? err?.statusCode ?? 404;
  return { statusCode };
};
export default Error;

// 📁 pages/404.tsx — 404 page (Pages Router only)
export default function Custom404() {
  return <h1>404 — Not Found</h1>;
}
💻 🅱️ App Router — Just 4 Files and You're Done
// ✅ App Router — Automatic application by file name only

// 📁 app/posts/page.tsx (Server Component)
interface Post { id: number; title: string; }

export default async function PostsPage() {
  const posts: Post[] = await fetch('https://api/posts', {
    next: { revalidate: 60 },
  }).then(r => {
    if (!r.ok) throw new Error('posts fetch failed');
    return r.json();
  });

  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

// 📁 app/posts/loading.tsx — Automatic Suspense fallback
export default function Loading() {
  return (
    <div className="animate-pulse space-y-2">
      <div className="h-4 bg-gray-200 rounded" />
      <div className="h-4 bg-gray-200 rounded" />
      <div className="h-4 bg-gray-200 rounded w-2/3" />
    </div>
  );
}

// 📁 app/posts/error.tsx — Automatic Error Boundary
'use client'; // ★ error is always Client

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <h2>Failed to load posts</h2>
      <p className="text-gray-600">{error.message}</p>
      {error.digest && <p className="text-xs">Error ID: {error.digest}</p>}
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

// 📁 app/posts/[id]/page.tsx — Specific post page
import { notFound } from 'next/navigation';

export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await db.post.findUnique({ where: { id: Number(params.id) } });
  if (!post) notFound(); // ★ calling this renders not-found.tsx
  return <article>{post.title}</article>;
}

// 📁 app/posts/[id]/not-found.tsx — Automatic 404 UI
import Link from 'next/link';
export default function NotFound() {
  return (
    <div>
      <h2>Post not found</h2>
      <Link href="/posts">← Back to list</Link>
    </div>
  );
}

// Folder tree:
// app/posts/
// ├── page.tsx          ← List page
// ├── loading.tsx       ← List loading UI
// ├── error.tsx         ← List error UI
// └── [id]/
//     ├── page.tsx      ← Detail page
//     └── not-found.tsx ← Detail 404 UI
// 0 lines of registration/import code. Works by file name only.

💡 💡 File Convention: 5 Practical Tips

1. error.tsx must always have 'use client'
React Error Boundaries are client-only by nature, so the convention enforces this. Omitting it will cause a build error.

2. error.tsx cannot catch errors from layout.tsx in the same folder
If a layout throws, the error.tsx one directory level up will catch it. Root layout errors are caught by global-error.tsx (Client only).

3. loading.tsx is shown only during the first await in page.tsx
It disappears once the page has finished rendering. It will reappear on subsequent client-side navigations.

4. notFound() is similar to throw — code below it does not run

tsx
if (!post) notFound();
console.log(post.title); // never reached (notFound terminates the flow)

TypeScript also narrows post to non-null after this point.

5. Keep all convention files in the same folder

code
posts/
├── page.tsx
├── layout.tsx
├── loading.tsx
├── error.tsx
└── not-found.tsx

The folder becomes a UI state machine — every state of the page is visible at a glance.

⚡ Try It Yourself — File Convention Mapping

Simulate which convention files apply, and in what order, for each URL.
✏️ JS 코드
📟 Console output
▶ Press the Run button
⚠️ Runs in a browser sandbox — only console.log() is supported; alert/fetch are not.

Check Your Understanding

Why must `error.tsx` in the App Router always be marked `'use client'`?
💡 React Error Boundary relies on class component lifecycle methods such as `componentDidCatch` and `getDerivedStateFromError`, which are features of **client-side React**. Because Next.js automatically wraps `error.tsx` in an Error Boundary, that component must be a Client Component — which is why `'use client'` is mandatory at the top of the file.
File Conventions — loading / error / not-found - Next.js