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:
/** @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:
- How do we pass data to a component?
- How do we initialize our applications?
- 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:
layout.tsx
: this component is the base layout. It replaces both_app. tsx
and_document.tsx
.loading.tsx
: this will render a component when our page issuspended
. For example, while loading data from our Server Component.head.tsx
: this will be the basehead
, which replaced thenext/head
component.not-found.tsx
: this component will be rendered when hitting a 404error.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:
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;
Don't worry if you do not see the "Sidebar" component, you'll be able to see the details in the source code linked at the end of this article.
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:
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.
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:
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
:
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:
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
.
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 "{query} " </Heading> <StoriesList stories={data.hits} /> </div> );}export default SearchPage;
And below is the API function:
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:
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:
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!