banner
AgedCoffee

AgedCoffee

Understanding the App Directory Structure of Next13

Original article link: Understanding App Directory Architecture In Next.js

Understanding the App Directory Structure in Next13#

Summary: The new App directory structure is a major feature recently released in Next.js, sparking much discussion. In this article, Atila Fassina explores the advantages and drawbacks of this new strategy and considers whether you should use it in production now.

Since the release of Next.js 13, there has been some debate about the stability of the new features included in its announcement. In the article "What’s New in Next.js 13?", we covered what was released and established that while Next.js 13 includes some interesting experiments, it is definitely stable. Since then, most of us have seen very clear cases for the new and components, and even (still in beta) @next/font; these are all ready to use with immediate benefits. As clearly stated in the announcement, Turbopack is still in alpha, only for development builds, and is still under active development. Whether you can use it in your daily work depends on your tech stack, as there are still some integrations and optimizations underway. The scope of this article is limited to the star of the announcement: the new app directory structure (referred to as AppDir).

The app directory has raised questions as it works in conjunction with an important evolution in the React ecosystem - React Server Components - and edge runtimes. It is clearly the shape of our Next.js applications in the future. While it is experimental, its roadmap is not something we can expect to be completed in the coming weeks. So, should you use it in production now? What advantages can you gain from it, and what pitfalls might you encounter? As always, the answer in software development is the same: it depends on different circumstances.

What is the App Directory?#

This is a new strategy for handling routing and rendering views in Next.js. It is achieved by combining several different features and is built to maximize the use of React's concurrent features (yes, we are talking about React Suspense). It brings a significant paradigm shift that changes the way you think about components and pages in your Next.js applications. This new way of building applications offers many very popular improvements to architecture. Here is a brief, non-exhaustive list:

  • Partial routing.
    • Route grouping.
    • Parallel routing.
    • Intercepting routes.
  • Server components vs client components.
  • Suspense boundaries.
  • And more, check out the feature overview in the new documentation.

Quick Comparison#

When it comes to the current routing and rendering architecture (in the Pages directory), developers need to consider how each route fetches data.

  • getServerSideProps: Server-side rendering;
  • getStaticProps: Server-side pre-rendering and/or incremental static regeneration;
  • getStaticPaths + getStaticProps: Server-side pre-rendering or static site generation.

Historically, there has been no way to choose a rendering strategy on a per-page basis. Most applications either fully adopt server-side rendering or fully adopt static site generation. Next.js has created enough layers of abstraction to make it standard to consider routing separately within its architecture.

Once the application reaches the browser, the hydration process kicks in, and by wrapping the _app component in a React Context Provider, routes can share data. This gives us the tools to place data at the top of the rendering tree and pass it down.

import { type AppProps } from 'next/app';

export default function MyApp({ Component, pageProps }: AppProps) {
  return (
        <SomeProvider>
            <Component {...pageProps} />
        </SomeProvider>
}

Being able to render and organize the required data in each route makes this approach a great tool when necessary to ensure data is globally propagated throughout the application. However, wrapping everything in a Context Provider ties hydration to the root of the application, making it impossible to render any branches on that tree (any route within that Provider context).

This is where the Layout pattern comes into play. By creating wrappers around pages, we can choose rendering strategies for each route rather than making an application-level decision all at once. For information on how to manage state in the Pages Directory, see the article "State Management in Next.js" and the Next.js documentation.

The Layout pattern has proven to be a great solution. The ability to finely define rendering strategies is a very popular feature. Thus, the App directory highlights the importance of the Layout pattern. As a first-class citizen in the Next.js architecture, it provides significant improvements in performance, security, and data handling.

With React's concurrent features, components can now be streamed to the browser, allowing each component to handle its own data. Therefore, rendering strategies are now more granular, based on components rather than pages. By default, Layouts are nested, which makes it clearer for developers to understand the impact of each page based on the file system architecture. Most importantly, components must be explicitly converted to client components (via the "use client" directive) before using Context.

Building Blocks of the App Directory#

This architecture is built around a layout for each page. The _app and _document components are no longer present. They have both been replaced by the root layout.jsx component. As you would expect, this is a special layout that wraps the entire application.

export function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}

The root layout is our way of returning the HTML for the entire application in one go. It is a server component that does not re-render on navigation. This means that any data or state in the layout will persist throughout the application's lifecycle.

While the root layout is a special component for our entire application, we can also define root components for other building blocks:

  • loading.jsx: Defines the Suspense boundary for the entire route;
  • error.jsx: Defines the error boundary for the entire route;
  • template.jsx: Similar to a layout but re-renders on each navigation. Particularly useful for handling state between routes, such as enter or exit transition effects.

All these components and conventions are nested by default. This means that /about will automatically be nested within the wrapper of /.

Finally, we also need to define a page.jsx for each route, which will define the main component corresponding to that URL segment (i.e., where you place the component). These are not nested by default and will only appear in our DOM when there is an exact match for the corresponding URL segment.

Of course, there is more architecture (and even more coming!), but this should be enough to correctly understand your mental model before considering migrating the production Pages directory to the App directory. Be sure to check the official upgrade guide.

Introduction to SERVER COMPONENTS#

React Server Components allow applications to leverage infrastructure to improve performance and overall user experience. For example, an immediate improvement is in bundle size, as RSC does not bring their dependencies into the final bundle. Since they are rendered on the server, any type of parsing, formatting, or component libraries will remain on the server code. Secondly, due to their asynchronous nature, server components are streamed to the client. This allows for progressively enhancing the rendered HTML in the browser.

As a result, server components lead to a more predictable, cacheable, and constant size of the final bundle, breaking the linear correlation between application size and bundle size. This immediately positions RSC as a best practice compared to traditional React components (now referred to as client components for disambiguation).

In server components, fetching data is also very flexible and, in my opinion, closer to native JavaScript - which always makes the learning curve smoother. For example, understanding the JavaScript runtime makes it possible to define data fetching as parallel or sequential, allowing for more granular control over resource loading waterfalls.

  • Parallel Data Fetching, waiting for all:
import TodoList from './todo-list'

async function getUser(userId) {
  const res = await fetch(`https://<some-api>/user/${userId}`)
  return res.json()
}

async function getTodos(userId) {
  const res = await fetch(`https://<some-api>/todos/${userId}/list`)
  return res.json()
}

export default async function Page({ params: { userId } }) {
  // Initiate both requests in parallel.
  const userResponse = getUser(userId)
  const todosResponse = getTodos(username)

  // Wait for the promises to resolve.
  const [user, todos] = await Promise.all([userResponse, todosResponse])

  return (
    <>
      <h1>{user.name}</h1>
      <TodoList list={todos}></TodoList>
    </>
  )
}
  • Parallel, waiting for one request, streaming another request:
async function getUser(userId) {
  const res = await fetch(`https://<some-api>/user/${userId}`)
  return res.json()
}

async function getTodos(userId) {
  const res = await fetch(`https://<some-api>/todos/${userId}/list`)
  return res.json()
}

export default async function Page({ params: { userId } }) {
  // Initiate both requests in parallel.
  const userResponse = getUser(userId)
  const todosResponse = getTodos(userId)

  // Wait only for the user.
  const user = await userResponse

  return (
    <>
      <h1>{user.name}</h1>
      <Suspense fallback={<div>Fetching todos...</div>}>
        <TodoList listPromise={todosResponse}></TodoList>
      </Suspense>
    </>
  )
}

async function TodoList({ listPromise }) {
  // Wait for the album's promise to resolve.
  const todos = await listPromise

  return (
    <ul>
      {todos.map(({ id, name }) => (
        <li key={id}>{name}</li>
      ))}
    </ul>
  )
}

In this case, the receives an ongoing Promise that needs to be awaited before rendering. The application will render the suspense fallback component until all operations are complete.

  • Sequential Data Fetching will trigger one request at a time and wait for each request:
async function getUser(username) {
  const res = await fetch(`https://<some-api>/user/${userId}`)
  return res.json()
}

async function getTodos(username) {
  const res = await fetch(`https://<some-api>/todos/${userId}/list`)
  return res.json()
}

export default async function Page({ params: { userId } }) {
  const user = await getUser(userId)

  return (
    <>
      <h1>{user.name}</h1>
      <Suspense fallback={<div>Fetching todos...</div>}>
        <TodoList userId={userId} />
      </Suspense>
    </>
  )
}

async function TodoList({ userId }) {
  const todos = await getTodos(userId)

  return (
    <ul>
      {todos.map(({ id, name }) => (
        <li key={id}>{name}</li>
      ))}
    </ul>
  )
}

Now, the Page will start rendering after fetching the getUser data and waiting for it. Once it reaches , it will fetch the getTodos data and wait for it. This is still more refined than the method we used in the Pages directory.

Important points to note:

  • Requests made within the same component scope will be issued in parallel (more on this in the extended Fetch API below).

  • The same request made in the same server runtime will be deduplicated (only one actually happens, the one with the shortest cache expiration time).

  • For requests that do not use fetch (such as third-party libraries like SDKs, ORMs, or database clients), the route cache will not be affected unless manually configured through segment caching.

export const revalidate = 600; // revalidate every 10 minutes

export default function Contributors({
  params
}: {
  params: { projectId: string };
}) {
    const { projectId }  = params
    const { contributors } = await myORM.db.workspace.project({ id: projectId })

  return <ul>{*/ ... */}</ul>;
}

This illustrates that developers can have more granular control over page rendering. In the pages directory, the page rendering would be unblocked only after all data is ready. With getServerSideProps, users would still see a loading animation until all data for the entire route is ready. To simulate this behavior in the App directory, fetch requests should be made in the layout.tsx of that route, so it should be avoided. An "all or nothing" approach is rarely what you need, and compared to this fine-grained strategy, it leads to worse perceived performance.

Extended Fetch API#

The syntax remains unchanged: fetch(route, options). However, according to the Web Fetch Spec, options.cache will determine how this API interacts with the browser cache. But in Next.js, it will interact with the framework's server-side HTTP cache.

When discussing Next.js's extended Fetch API and its caching strategy, there are two values to understand:

  • force-cache: The default option, looks for the latest match and returns it.
  • no-store or no-cache: Fetches from the remote server on every request.
  • next.revalidate: The same syntax as ISR, sets a hard threshold to consider whether a resource is fresh.
fetch(`https://route`, { cache: 'force-cache', next: { revalidate: 60 } })

The caching strategy allows us to categorize our requests:

  • Static data: Longer retention times. For example, blog posts.
  • Dynamic data: Frequently changing and/or the result of user interactions. For example, comment sections, shopping carts.

By default, every piece of data is treated as static data. This is due to force-cache being the default caching strategy. To completely exclude the influence of dynamic data, no-store or no-cache can be defined.

If using dynamic functions (such as setting cookies or headers), the default will switch from force-cache to no-store!

Finally, to achieve something more similar to incremental static regeneration, you need to use next.revalidate. The benefit is that it only defines the component it is in, not the entire route.

Migrating from Pages to App#

Migrating logic from the Pages directory to the App directory may seem like a lot of work, but Next.js is already prepared to allow these two architectures to coexist, so migration can be done gradually. Additionally, there is a very good migration guide in the official documentation; I recommend reading it thoroughly before starting your refactoring.

Guiding you through the migration path is beyond the scope of this article and would duplicate the documentation. Instead, to add value on top of what the official documentation provides, I will try to offer some insights to help you avoid friction points.

The Case of React Context#

To provide all the advantages mentioned in this article, RSC cannot be interactive, meaning they do not have hooks. Therefore, we decided to push client logic towards the leaf nodes of the rendering tree as much as possible; once interactivity is added, the children of that component will be client-side.

In some cases, it will be impossible to delay pushing certain components (especially if some key functionality depends on React context, for example). Since most libraries are prepared to protect their users from Prop Drilling, many libraries create context providers to skip from the root to distant descendant components. Therefore, completely abandoning React context may cause some external libraries to malfunction.

As a temporary solution, there is a client wrapper for this issue:

// /providers.jsx
‘use client’

import { type ReactNode, createContext } from 'react';

const SomeContext = createContext();

export default function ThemeProvider({ children }: { children: ReactNode }) {
  return (
    <SomeContext.Provider value="data">
      {children}
    </SomeContext.Provider>
  );
}

Thus, the layout component will not complain about skipping the rendering of a client component.

// app/.../layout.jsx
import { type ReactNode } from 'react';
import Providers from ‘./providers’;

export default function Layout({ children }: { children: ReactNode }) {
    return (
    <Providers>{children}</Providers>
  );
}

It is important to realize that once this is done, the entire branch will become client-rendered. This method will ensure that everything within the component will not be rendered on the server side, so use it only as a last resort.

Typescript and Asynchronous React Elements#

When using async/await outside of Layouts and Pages, TypeScript will generate errors based on the expected response types matching its JSX definitions. While it is still available and supported at runtime, according to Next.js documentation, this needs to be fixed upstream in TypeScript.

The current solution is to add a comment on the line above {/* @ts-expect-error Server Component */}.

Client-Side Data Fetching Work#

Historically, Next.js did not have built-in data mutation capabilities. Requests made from the client were up to developers to decide how to handle. With React Server Components, this situation has changed. The React team is developing a use hook that will accept a Promise, then handle that Promise and directly return its value.

In the future, this will replace most of the useEffect currently in use (for more on this, see the excellent talk "Goodbye, UseEffect"), and may become the standard for handling asynchronous operations (including fetching) in client-side React.

Currently, it is still recommended to rely on libraries like React-Query and SWR to meet your client-side fetching needs. Pay particular attention to the fetching behavior!

So, is it ready?#

Experimentation is the essence of progress, and we cannot make a delicious omelet without breaking some eggs. I hope this article helps you answer this question for your specific use case.

If it’s a new project, I might try using the App directory and keep the Page directory as a fallback or for critical business functionality. If it’s a refactor, it depends on how much client-side data fetching I have. If it’s minimal, I can start refactoring; if it’s substantial, I might want to wait for a complete solution.

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.