Server components enable you to render the entire pages on the server without messing around with API endpoints and data fetching logic. But handling the different states remains an issue for the optimal usability experience. Without handling these states the user interface feels sluggish while waiting for the server response and an error would render the entire screen useless with a white screen of death. Not ideal, but strategies to mitigate these undesirable behaviors do exist and are easy to use.

Adding loading and error state

I am using server components to render a list of AI-generated snippets on AIHelperBot. It has the following folder structure:

-- app/
  -- snippets/
    -- error.tsx
    -- loading.tsx
    -- page.tsx

Both error.tsx and loading.tsx export a default React component that is displayed instead of the page.tsx when an error occurs or when loading. The loading.tsx looks like this:

import { H1AsH2, Link, P } from '@/components/Typo';

export default function Loading () {
  return (
    <>
      // Breadcrumbs omitted for brevity...
      <div className="mx-auto max-w-4xl p-4">
        <div className="mb-8">
          <H1AsH2>Snippets</H1AsH2>
          <P>
            Your snippet library. You can create new snippets using the <Link href="/app">AI Generator</Link>.
          </P>
        </div>
        <div role="status" className="dark:divide-gray-700 animate-pulse space-y-4 divide-y divide-gray-200 rounded">
          <div className="flex items-center justify-between">
            <div>
              <div className="dark:bg-gray-600 mb-2.5 h-2.5 w-24 rounded-full bg-gray-300"></div>
              <div className="dark:bg-gray-700 h-2 w-32 rounded-full bg-gray-200"></div>
            </div>
            <div className="dark:bg-gray-700 h-2.5 w-12 rounded-full bg-gray-300"></div>
          </div>
          // Repeated skeleton pattern omitted for brevity...
          <span className="sr-only">Loading...</span>
        </div>
      </div>
    </>
  );
}

And the error.tsx:

'use client';

import { Button } from '@/components/Button';
import { H1AsH2 } from '@/components/Typo';
import { Alert } from '@/components/Alert';

export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <>
      // Breadcrumbs omitted for brevity...
      <div className="mx-auto max-w-4xl p-4">
        <H1AsH2>Snippets</H1AsH2>
        <Alert variant="error">{error.message}</Alert>
        <Button color="light" onClick={reset}>
          Try again
        </Button>
      </div>
    </>
  );
}

Both are simple stateless React components.

Visualizing loading and error states

Here are the snippet page and its different states.

Server-side rendered snippet page

(Please excuse the local test data)

AIHelperBot snippet page

Snippet loading state

It is a pulsating skeleton which displays immediately when navigating to the snippet page.

AIHelperBot snippet loading state

Snippet error state

An error occurred on the server while rendering the page.

AIHelperBot snippet error state

Both the loading and error states are easy to work with since they merely contains JSX and Next.JS handles switching between them. But how to test them? Of course you could manually throw an error or artificially extend the duration of a database call, but those hacks to test the states might accidentally slip into production and cause bugs. Besides it is cumbersome work flow constantly having to add, remove or uncommend testing code.

Testing loading and error state

To enable easy testing you can add a helper function that enables you to switch between the states using URL parameters:

/**
 * Test loading and error layouts for NextJS's app dir.
 * @param searchParams
 * @returns Promise
 */
export const layoutTester = async (searchParams: Record<string, string>) => {
  if (process.env.NODE_ENV !== 'development') {
    return;
  }

  if (typeof searchParams.loading !== 'undefined') {
    const loading = parseInt(searchParams.loading || '2000');
    await new Promise((resolve) => setTimeout(resolve, loading));
  }

  if (typeof searchParams.error !== 'undefined') {
    const error = searchParams.error || 'Something went wrong!';
    await new Promise((_resolve, reject) => reject(error));
  }
};

Use it by adding it to the server component, in this case the app/snippets/page.tsx:

import { layoutTester } from '@lib/layoutTester';

// Next.JS isn't exporting a type for the server component props unfortunately
export default async function Snippet (props: { searchParams: Record<string, string> }) {
  await layoutTester(props.searchParams);

  // Do what else you need to like checking credentials 
  // and connecting to the database

  return (
    <div>
      {/*Omitted for brevity */}
    <div>
  )
}

To test the different states simple add the parameters to the page URL (e.g. http://localhost:3000/app/snippets?loading):

  • loading state append ?loading or ?loading=10000 (duration in ms)
  • error state append ?error or ?error="Custom error message."
  • loading and error state append ?loading&error

Appending these parameters only works in development mode. It makes it easy to ensure a smooth flow and sane error handling.