Getting Started with Next.js Server Components

A simple introduction to using Server Components and the new Layouts Folder Structure with Next.js 13

Vercel has finally shipped Next.js 13, and with it one of the most requested features: Layouts. The new app structure is basically a new framework, full of new functionalities and possibilities that were simply impossible before.

As a result, Next 13 is easily the de-facto framework that React 18 envisioned when it was released, using the full power of Suspense and Server Components.

The changes and possibilities Next.js 13 brings are revolutionary: thanks to the new Server Components and seamless Suspense integration, Next. js applications will be smaller and faster.

Yet, the transition to using Server Components won't be the simplest: as I said above, the new Next.js 13 Layouts are basically a new framework within the old one.

In this blog post, I'm to write a step-by-step tutorial to build a Hacker News Clone using Next.js 13 and utilizing only the new Server Components Layouts.

This blog post is great for those who want to see a quick overview of how Server Components work with Next.js.

Are you ready? Let's start.

Getting Started

To get started, let's start a new Next.js application with create-next-app:

npx create-next-app --ts

After following the prompts, we open the new folder in the IDE.

To enable the new app directory, we add the experimental flag to the Next.js configuration file:

next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
experimental: {
appDir: true,
enableUndici: true
}
}
module.exports = nextConfig;

Additionally, we added the property enableUndici: true, which uses the undici package instead of node-fetch. The node-fetch package has a weird bug of not allowing payloads larger than 15kb. Weird.

Now, I'll simply delete everything inside the pages folder, with the exception of the api folder: Next.js requires the API functions to be placed within pages, for the time being.

So, are we deleting _app.tsx and _document.tsx? Yes. These are no longer needed when working with the new Next.js app directory. Gone. Done. Ciao.

This introduces lots of question marks:

  1. How do we pass data to a component?
  2. How do we initialize our applications?
  3. Where do we place our app-wide Providers?

Believe me, I was as confused as you are, and still am while writing this. But let's go on.

Tree Structure

The new folder tree structure adds some conventions that we need to use for handling specific states: for example, error boundaries, loading skeletons, the page's heading, etc.

This is how our folder structure will look like:

├── lib
└── api.ts
└── types.ts
├── app
├── shared
│ ├── ask
└── page.tsx
│ ├── top
└── page.tsx
│ ├── latest
└── page.tsx
│ ├── latest
└── search.tsx
│ ├── stories
└── [id]
└── page.tsx
└── loading.tsx
│ ├── loading.tsx
│ ├── error.tsx
│ ├── not-found.tsx
│ ├── layout.tsx
│ ├── head.tsx

As you can see from the above, every actual page needs to be named page.tsx. If it's named anything but one of the special file names above, it's simply another file we can import. This improves colocation, so we can add our files close to where they're used.

Furthermore, you can see ewe added other "special" files:

  1. layout.tsx: this component is the base layout. It replaces both _app. tsx and _document.tsx.
  2. loading.tsx: this will render a component when our page is suspended. For example, while loading data from our Server Component.
  3. head.tsx: this will be the base head, which replaced the next/head component.
  4. not-found.tsx: this component will be rendered when hitting a 404
  5. error.tsx: this component will render when catching an uncaught exception.

Creating the Root Layout

All the Next.js applications using the new app folder structure need to define a root layout. This component will replace the existing _app.tsx and _document.tsx files.

To create the root layout, we need to add a layout.tsx file in the app folder: this is the root component that will be applied to every page that you add to your app folder.

In the case of our application, it will look like the below:

app/layout.tsx
import '../styles/globals.css';
import Sidebar from './shared/Sidebar';
function RootLayout({ children }: React.PropsWithChildren) {
return (
<html lang={'en'}>
<body>
<div className={'flex'}>
<div
className={'w-2/12 max-w-xs dark:bg-black-400 bg-gray-50 h-screen'}
>
<Sidebar />
</div>
<div
className={'bg-white dark:bg-black-500 w-full overflow-x-hidden'}
>
<div className={'relative mx-auto h-screen w-full overflow-y-auto'}>
<div className={'p-6'}>{children}</div>
</div>
</div>
</div>
</body>
</html>
);
}
export default RootLayout;

Creating the root Head

The special head.tsx component is meant to replace the Head component we import from next/head.

By adding the root head component, every page will automatically inject the head component defined below:

app/head.tsx
function Head() {
return (
<>
<title>Next.js Layouts Demo</title>
</>
);
}
export default Head;

Creating a Loading spinner for the root layout

When the component is suspended, we can render a loading indicator just by adding layout.tsx to our root.

app/loading.tsx
function Loading() {
return (
<ContentLoader
width={1500}
height={400}
viewBox="0 0 1500 400"
backgroundColor="#777"
foregroundColor="#999"
>
{
// more code here
}
</ContentLoader>
}

Adding Server Components Pages

Ok, now let's add our root page component. To add pages, you will use the convention page.tsx. The root page.tsx represents the home page of our application.

In this page will render the Hacker News front page items. So, we will loading the items using the function getFrontPage.

Let's define that first. To create our clone, we will be using the Hacker News Algolia API:

lib/api.ts
const BASE_URL = `https://hn.algolia.com/api/v1`;
export async function getFrontPage() {
return fetch(`${BASE_URL}/search?tags=front_page`)
.then((r) => r.json())
.then((r) => r.hits);
}

Now, we import the function getFrontPage and we wrap it in the special (and not stable) React hook named use. Thanks to this hook, the component will suspend until the data is fetched.

As you can see from the below, we don't even need to use async/await:

app/page.tsx
import { use } from 'react';
import Heading from './shared/Heading';
import { getFrontPage } from '../lib/api';
import StoriesList from './shared/StoriesList';
import SearchBar from './shared/SearchBar';
function HomePage() {
const stories = use(getFrontPage());
return (
<div className={'flex flex-col space-y-6'}>
<SearchBar />
<Heading>Front Page</Heading>
<div>
<StoriesList stories={stories} />
</div>
</div>
);
}
export default HomePage;

Using Page Parameters

Let's assume we want to use a search bar, and we will display the results in the search/page.tsx page.

Let's create a simple search component: this component will submit a GET request to the /search page, and will append the input query as a query parameter:

shared/SearchBar.tsx
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
function SearchBar(
props: React.PropsWithChildren<{
value?: string | undefined;
}>
) {
return (
<form className={'w-full'} action={'/search'} method={'GET'}>
<div
className={
'dark:bg-black-300 flex space-x-4 items-center transition-all dark:focus-in:bg-black-400 bg-gray-100 focus-in:bg-gray-200 rounded-lg px-4 py-3 w-full'
}
>
<MagnifyingGlassIcon className={'h-5'} />
<input
autoComplete={'off'}
className={'bg-transparent w-full dark:bg-black-300 outline-none'}
name={'query'}
defaultValue={props.value}
type="text"
placeholder={'Search...'}
/>
</div>
</form>
);
}
export default SearchBar;

Now, we can create our search/page.tsx page that will read the query and submit a request to the HN API.

In the component below, we can also see how to redirect users without the ctx API we used to use using getServerSideProps.

search/page.tsx
import { use } from 'react';
import { redirect } from 'next/navigation';
import StoriesList from '../shared/StoriesList';
import { getResultsFromQuery } from '../../lib/api';
import SearchBar from '../shared/SearchBar';
import Heading from '../shared/Heading';
function SearchPage(props: { searchParams: Record<string, string> }) {
const query = props.searchParams.query;
if (!query) {
return redirect('/');
}
const data = use(getResultsFromQuery({ query, page: 0 }));
return (
<div className={'flex flex-col space-y-6'}>
<SearchBar value={query} />
<Heading>
{data.nbHits.toLocaleString()} Search Results found for &quot;{query}
&quot;
</Heading>
<StoriesList stories={data.hits} />
</div>
);
}
export default SearchPage;

And below is the API function:

lib/api.ts
export async function getResultsFromQuery(params: {
page: number;
query: string;
}) {
const queryParams = `tags=story&hitsPerPage=100&page=${params.page}&query=$
{params.query}`;
return fetch(`${BASE_URL}/search?${queryParams}`).then((r) => r.json());
}

Using Page segment parameters

Finally, let's see an example to retrieve the page's segment parameters using the new Server Components. The page below will display the detailed data from a Hacker News story.

To add a dynamic segment, we will simply add a [id] folder, and add the page.tsx file within it:

app/story/[id]/page.tsx
import { use } from 'react';
import { redirect } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
import { getCommentsByStoryId, getItemById } from '../../../lib/api';
import Heading from '../../shared/Heading';
import CommentsSection from '../../shared/CommentsSection';
import { StoryItemChild } from '../../../lib/types';
function StoryPage(props: { params: Record<string, string> }) {
const id = props.params.id;
if (!id) {
return redirect('/');
}
const item = use(getItemById(id));
const comments = use(getCommentsByStoryId(id));
return (
<div>
<div className={'flex flex-col space-y-4'}>
<Link className={'hover:underline'} href={'../'}>
<span className={'flex items-center text-sm font-medium space-x-2'}>
<ArrowLeftIcon className={'h-4'} />
<span>Back</span>
</span>
</Link>
<div className={'flex flex-col space-y-2'}>
<Heading>
{item.url ? (
<Link
className={'hover:underline'}
target={'_blank'}
href={item.url}
>
{item.title}
</Link>
) : (
item.title
)}
</Heading>
<div className={'text-sm text-gray-500 dark:text-gray-400'}>
<p>
{item.points} points by {item.author}
</p>
</div>
</div>
<div className={'max-w-2xl'}>
<StoryChildrenRenderer items={item.children} />
</div>
<div>
<CommentsSection comments={comments} />
</div>
</div>
</div>
);
}
function StoryChildrenRenderer(
props: React.PropsWithChildren<{
items: StoryItemChild[];
}>
) {
return (
<div className={'flex flex-col space-y-4'}>
{props.items.map((child) => {
return (
<div key={child.id}>
<div dangerouslySetInnerHTML={{ __html: child.text }} />
<StoryChildrenRenderer items={child.children ?? []} />
</div>
);
})}
</div>
);
}
export default StoryPage;

Below we define the API function to fetch an item and its comments (which are not nested, for simplicity):

export async function getItemById(storyId: string): Promise<StoryItem> {
return fetch(`${BASE_URL}/items/${storyId}`).then((r) => r.json());
}
export async function getCommentsByStoryId(
storyId: string
): Promise<StoryComment[]> {
return fetch(`${BASE_URL}/search?tags=comment,story_${storyId}`)
.then((r) => r.json())
.then((response) => response.hits);
}

As we want to display a different loading screen for the item's page, we can add a loading.tsx component within the story folder:

app/story/loading.tsx
import { Facebook } from 'react-content-loader';
function Loading() {
return (
<Facebook
width={1500}
height={400}
viewBox="0 0 1500 400"
backgroundColor="#777"
foregroundColor="#999"
/>
);
}
export default Loading;

Source Code

You can grab the full source code on GitHub. Please follow the README to run the example locally. If you have any question, please shoot!