Updating to Next.js 13's App Directory
2 months ago, Next.js 13 was released with a huge major new feature, sometimes referred to as "App Directory". It's a new routing implementation, which sits at the core of Next.js, and changes the way how apps are rendered quite a bit. I'm using Next.js for personal projects and at work, so this Blog is perfect to play around with those new changes.
Playing around I did... and reverted it after an afternoon of work.
I decided to wait for a few patch versions, gave it another try, and it finally worked. 🥳 So here's a summary of what had to change:
The biggest change are Server Components. Components can now be async and are evaluated on the Server, and by default, all Page Components are now Server Components.
Previously you had to fetch data inside an async function called getStaticProps()
, which had to return JSON-serializable data, which was then passed as Prop into your Page Component.
With async Server Components, this isn't anymore necessary. You can directly fetch data inside the Component. It makes everything incredibly simple, like it should be. This is how I now render the list of all posts:
// app/posts/page.tsx
export default async function Posts() {
const posts = await getBlogPosts()
return (
<ul className="space-y-4">
{posts.map((post) => (
<li key={post.slug}>
<Link href={`/posts/${post.slug}`}>{post.title}</Link>
</li>
))}
</ul>
)
}
And it's similarly simple to render a single post:
// app/posts/[slug]/page.tsx
export async function generateStaticParams() {
return []
}
export default async function Post({ params }) {
const post = await getBlogPost(params.slug)
if (!post) notFound()
return (
<>
{/* @ts-expect-error Server Component */}
<PostTitle title={post.rawTitle} />
{/* @ts-expect-error Server Component */}
<PostBody body={post.rawBody} />
<PostComments discussionNumber={post.discussionNumber} />
</>
)
}
Being an early adopter also means that you have to deal with tools which don't yet support your fancy new stuff. That's the case with TypeScript here: it thinks that async functions cannot be rendered as Components, and throws an error.
This will certainly be fixed in the future. You can check the corresponding TypeScript PR microsoft/TypeScript#51328 to see the status of it.
Before Server Components, I transformed the Markdown to HTML directly after fetching the data from GitHub inside getStaticProps()
, and passed those HTML-strings into the Components. It was the only way to do that, because the transformation is async, and async code could only be executed before starting to render.
Now this can be done inside the Component, which packages the concern neatly within a single piece of code:
export async function PostTitle({ title }) {
const htmlTitle = await markdownToHtml(title)
return <span dangerouslySetInnerHTML={{ __html: htmlTitle }} />
}
You might've noticed that I have this generateStaticParams
function, which just returns an empty array. What's that all about?
generateStaticParams
lets you define a list of URLs which should be generated at build time. I don't want generation at build time, I want it dynamically during runtime. So I thought I could just omit this function completely. And it seemed like I could, because everything was still working – everything except caching.
Unfortunately, I did not think that the problems with caching are correlated to the missing generateStaticParams
function. I debugged this for what felt like an eternity. I thought that the API requests to GitHub are causing the cache to be invalidated1. Of course you cannot test caching in dev mode, you have to build the site and run it in production mode, which made this very tedious.
I'm not sure if it was by coincidence or if I found it in a GitHub Issue, but after some time, I added the the empty generateStaticParams
in there, and it finally worked.
The Giscus comments widget is a client-only Component, it should initiate async on the client. PostTitle
and PostBody
are rendered on the server, which is now the default. But you can opt-in to client-side rendering by adding 'use client'
to the top of the file.
// app/posts/[slug]/PostComment.tsx
'use client'
export function PostComments({ discussionNumber }) {
return (
<Giscus
repo="timomeh/timomeh.de"
mapping="number"
term={discussionNumber.toString()}
/>
)
}
With Server- and Client-Components, it's easy to think that Server-Components are cached and Client-Components aren't cached, because "they are only on the Client", aren't they?
But pre-rendering is still a thing, and server-rendering didn't replace it. Client-Components are still pre-rendered and hydrated by default.
I think this can be a bit confusing in the beginning? At least I first had to wrap my head around it. There's a matrix of different Components types and Rendering modes, and then there's also Caching mixed into it. It certainly introduces a level of complexity into the mental model, not into the code itself.
For smaller projects, go ahead and use the new App Directory. I wouldn't recommend blindly migrating an important production app, since it's still in beta and there are some bugs and missing pieces. But all in all, I'm a huge fan of those new features. It reimagines how we write React Apps quite a bit.