How to Structure Next Applications

April 30, 2023 7 minute read

React finally got better docs this year and right now they’re the best resource to learn the technology and its ecosystem. One of the statements in it that surprised seasoned developers the most was that the core team now suggested the use of a meta framework like Next by default.

React’s complexity has undoubtedly skyrocketed in the last few years, making the learning curve steeper for newcomers. And a framework will remove some of the complexity that engineers have to deal with, letting them focus on writing business logic instead of configuring SSR.

But with this, React is effectively turning into a low-level library that’s meant to be abstracted away. Especially with all its new features built for library authors instead of engineers working on products.

Turning to a framework to resolve common problems is something we’ve been doing for quite some time. It’s the productive thing to do. But I’ve found that it’s even more important to continue following good design principles, or you will just trade one set of problems for others.

How Frameworks Affect Design

The question that many people rightfully asked me is whether people using frameworks by default will affect the rules about application structure from Tao of React. After all, Next’s router is file based and its new layouting feature is too based on file location.

This is bound to have significant impact on how we build applications.

But I believe that by following evergreen development principles, we can design our apps in such a way that they are (mostly) oblivious of the framework or environment they run in. We can apply the same practices described in Tao of React to confine Next.

But we want to do it in such a way that allows us to get the most out of it, still have a productive domain-focused application structure, and not over-engineer it along the way.

Structuring Routes and Pages

One of the most important principles in software design is the one of co-location. Ideally, you want things that work together to live together. But Next’s router is file-based and this immediatelly throws a wedge in the previous idea.

├── app
| | ├── page.tsx
| | ├── layout.tsx
| | ├── dashboard
| | | | ├── page.tsx
| | ├── settings
| | | | ├── page.tsx

Even if we organize the rest of our application into modules based on the domain, some of the functionality will live in the pages - data fetching, analytics, components, and at least some state.

export default function Page({ user }: PageProps) {
  const { items, isLoading } = useItems()

  return (
    <Layout title="All items" user={user}>
      <ItemList
        items={items}
        loading={isLoading}
        pagination={false}
        renderItem={(item) => <ListItem item={item} />}
      />
    </Layout>
  )
}

export async function getServerSideProps(context: NextPageContext) {
  // fetch data, check user permissions, etc.
  return {
    // ...
  }
}

This leads to fragmentation in the structure because even if you use modules to organize your logic, a significant part of each one of them will be outside. You will have to think of where exactly to put hooks, reusable components, and utility functionality.

We can deal with this problem by adopting an idea from hexagonal architecture, and using Next’s file-based router as an adapter over our logic.

We can leave all our logic living in a module, and only expose a page component which can be rendered in Next’s pages.

├── app
| | ├── page.tsx
| | ├── layout.tsx
| | ├── dashboard
| | | | ├── page.tsx
| | ├── settings
| | | | ├── page.tsx
├── modules
| | ├── items
| | | | ├── ItemsPage.tsx
| | | | ├── components
| | | | | | ├── ...
| | ├── dashboard
| | | | ├── DashboardPage.tsx
| | | | ├── components
| | | | | | ├── ...
| | ├── settings
| | | | ├── SettingsPage.tsx
| | | | ├── components
| | | | | | ├── ...
// ✅ Leave Next's page only as a light layer on top of your page component
export default function Page({ user }) {
  return <ItemsPage user={user} />
}

// ✅ Keep the server-side props logic in the page as well
export async function getServerSideProps(context: NextPageContext) {
  // fetch data, check user permissions, etc.
  return {
    // ...
  }
}

// 👉 modules/items/ItemsPage.tsx - Move the actual functionality here
export default function ItemsPage({ user }: PageProps) {
  const { items, isLoading } = useItems()

  return (
    <Layout title="All items" user={user}>
      <ItemList
        items={items}
        loading={isLoading}
        pagination={false}
        renderItem={(item) => <ListItem item={item} />}
      />
    </Layout>
  )
}

We still use the getServerSideProps function in the page to prefetch data, validate permissions and anything else that needs to happen prior to rendering. But the actual components and the logic related to them happens in the modules directory.

This way we can use the structuring patterns I’ve written about in Tao of React. I’ve seen this pattern applied in many companies that use Next and I’ve been applying it with great success so far.

Logic in Next-Specific Methods

Next offers us a handy place to execute and server-side or preparation logic we need for our pages. However, this is another spot where we could potentially leak domain logic, leading to code duplication and reduced maintainability.

But if we abstract this logic in services or objects, we can effectively remove it and only leave the decision-making logic related to routing in there.

export async function getServerSideProps(context: NextPageContext) {
  const user = authService.getUser(context.request)
  const queryClient = new QueryClient()

  await queryClient.prefetchQuery(
    queryKeys.lists,
    itemService.getAllItems
  )

  return {
    props: {
      user,
      dehydratedState: dehydrate(queryClient),
    },
  }
}

The itemService can be utilized in the client when we’re making a useQuery call or in a custom hook. The authService can be used in an API route if we need it.

By wrapping our logic, we make sure that we operate with descriptive method calls instead of imperative logical flows that expose how our domain works. In this example, the Next page will only pass down the user object, it won’t even have to forward the items data since we will have it available in the query cache.

This is still some sort of a leaky abstraction, since we abstract the knits and bolts of the logic but the high-level information is still in the method. A pattern I’ve seen some teams approach to solve this is by defining a provider object or service that handles all data collection and authorization for a page.

export async function getServerSideProps(context: NextPageContext) {
  const { req, query } = context
  const props = itemsPageProvider.getPageProps(req, query)
  return { props }
}

And the provider can live together with the page in its module.

├── modules
| | ├── items
| | | | ├── ItemsPage.tsx
| | | | ├── itemsPageProvider.ts

You will notice that the provider doesn’t accept the context directly but the objects in it, instead. This is to further decouple it from the framework and only make it aware that it’s running in an HTTP context.

This can lead to more verbosity but it hides all the details away, but I’d urge you to be careful with this approach. It’s not that well-known and developers can find it confusing, especially if the server-side logic for a page is more complex.

Layouts

Next 13 introduced the concept of layouts and nested layouts, allowing us to define the skeleton of a page in a separate file, and automatically nesting it.

├── app
| | ├── page.tsx
| | ├── layout.tsx
| | ├── ...

We can apply the same pattern we used to abstract the page component, by using the layout.tsx file only as a facade on top of a component defined elsewhere - for example, in a common or shared module.

├── app
| | ├── layout.tsx
| | ├── ...
├── modules
| | ├── common
| | | | ├── Layout.tsx
| | ├── ...

Alternatively, I’ve seen teams whose products require multiple layouts to put them in a separate top-level directory.

├── app
| | ├── layout.tsx
| | ├── ...
├── layouts
| | | | ├── CenteredLayout.tsx
| | | | ├── TwoColumnLayout.tsx
| | | | ├── CenteredWithSidebarLayout.tsx

Contexts and Providers

Another design topic worth discussing here is contexts and providers. Some of them need to wrap as much of your application as possible, while others you probably need for a single page or two.

When working with Next, we usually put providers that need to be defined on the top level in _app.tsx. In theory, this is in conflict with the structuring pattern that we’ve been talking about so far because it leaks domain details.

But in practice, it’s not the end of the world.

Context providers and app-level functionality is most often set up once and forgotten. We rarely have to change auth, styling tools, and data fetching libraries. So if I’m working on a project that has them defined in _app.tsx, I wouldn’t push for a refactoring.

But if you want to do things by the book, you can define a custom AppContextProvider component that abstracts all those providers and hides the details of their instantiation. We can then use it in _app.tsx instead, and still keep the Next directory clean of business details.

The Bottom Line

Everything in this article points to the same idea of establishing layers and putting boundaries between them. The fact that we’re using a framework doesn’t mean that our application should be modelled after the framework’s generic design.

Instead, we should be looking for ways to get the most out of Next and still keep our application free of framework-specific details.

A sign of a good software design is how easy it would be to pick up your business logic and run it in a different environment. Not because you’ll ever need to, but because it’s a good proxy metric for how well-established your layers are.

Having well-defined layers and boundaries means that changes to your software will be more contained and require less refactoring.

Change is the only constant, and we should aim to optimize for it.

Tao of Node

Learn how to build better Node.js applications. A collection of best practices about architecture, tooling, performance and testing.