Next.js uses a very basic file system routing. Any files and folders that we create within the /pages
subfolder will be used as the routes for our application. This approach is very convenient because we don't have to maintain huge configuration files and it's straightforward for anyone to understand.
However, one caveat of this approach is that when we extract subcomponents in external files, we have to move them outside the /pages
folder, typically in a separate /components
folder. Otherwise, Next.js will create a route for each of the component files:
app/
└─ pages/
├─ blog.tsx
├─ BlogPost.tsx
└─ SubscribeForm.tsx
For example, the above structure will create 3 routes: /blog
, /BlogPost
and /SubscribeForm
, even if our intention is to use BlogPost.tsx
and SubscribeForm.tsx
only as subcomponents of blog.tsx
.
.tsx
extension for React components because I useTypeScript, but everything in this article equally applies to JavaScript pages as well, so you can use .js
or .jsx
extensions.Another thing to keep in mind is that any component inside the /pages
folder must define a default export
. Otherwise, we'll get a build error:
// using a named export, without a default export
export function BlogPost() {
// ...
}
> Build error occurred
Error: Build optimization failed:
found page without a React Component as default export
in pages/BlogPost
Separate components folder
Generally speaking, there's nothing wrong with moving the components to a separate /components
folder, especially when we want to reuse them. But surely we'll also have to deal with components that are used only on a specific page. This brings up 2 annoying aspects.
As our application gets bigger, we'll need to figure out how to structure the growing number of components. One way would be to replicate the /pages
structure inside the /components
folder:
app/
├─ components/
│ ├─ shared/
│ ├─ index/
│ └─ blog/
│
└─ pages/
├─ index.tsx
└─ blog.tsx
But you know that names are not set in stone and they will change in time. At some point, we'll need to manually rename multiple files and folders just to make sure that their names are in sync. It's not a deal-breaker, but it requires a constant mainteinance effort.
2. Potential orphan componentsAnother downside is that when deleting a page, the components that are used only on that particular page won't get deleted by default. It's not trivial to figure out which components are not needed anymore and could be safely removed. Most likely they will remain in the codebase.
The bundle size won't be negatively affected, because the unused components won't be included in any other component. But anyone looking at them will always ask the question:
Do we still need this component or not?
There's a workaround we can use to avoid the above shortcomings which I find to improve maintainability, at least in some situations.
Custom page extensions
The fundamental reason that we get the above limitations is that by default, Next.js cannot distinguish between pages that translate to routes and components that are used in pages, because both are defined as regular React components.
Fortunately, we can customize what should be considered a page using the pageExtensions
setting inside our next.config.js
file. For instance, if we use:
module.exports = {
pageExtensions: ["page.tsx"],
};
... and rename our blog.tsx
to blog.page.tsx
:
app/
└─ pages/
├─ blog.page.tsx
├─ BlogPost.tsx
└─ SubscribeForm.tsx
... the build step will only generate the /blog
route for the blog.page.tsx
and skip any components that don't match the page.tsx
extension.
Another minor benefit we get with this approach is that we can safely use named exports from our components because the build system will not treat them as pages anymore. Keep in mind that we do need to use default exports for any of the files that we have configured to be rendered as pages, like blog.page.tsx
for example.
With this setting, we can co-locate the subcomponents specific to a single page, by moving them in the same folder:
- renaming the route won't affect the structure of the components;
- deleting the route will also remove any components used by that route.
app/
└─ pages/
└─ blog/
├─ index.page.tsx
├─ BlogPost.tsx
└─ SubscribeForm.tsx
We can create any arbitrary subfolders to better structure and separate our components, without generating unneeded routes:
app/
└─ pages/
└─ blog/
├─ components/
│ ├─ BlogPost.tsx
│ └─ SubscribeForm.tsx
└─ index.page.tsx
Practical use case
I found this approach very useful for complex blog posts, especially the ones that contain custom UI components because it helps me to encapsulate all the files required for a blog post within the same folder. This means that I can:
- safely delete a blog post, without worrying that there might be some other files that I should remove because they are used only in that particular blog post;
- rename, move or refactor a blog post, without worrying that I have to rename anything else in other parts of the application.
Criticism
One downside of this approach is that the routing is somewhat obscured by the additional components that are not rendered as routes. While there is a distinction in the filename between pages and components because pages have the page.tsx
extension, it might not be obvious at first glance.
So, in the end, the question is:
Routing readabilityWhat is more important to us? Routing readability? Or page content encapsulation?
The default approach of separating pages and components in different folders is convenient when 1) our application has a large number of routes that should be easy to understand, or 2) the pages reuse mostly the same components and don't have a large number of custom components that we have to figure out how to organize them.
Page content encapsulationUsing custom page extensions can be useful when 1) our application has a smaller number of routes, 2) most pages are custom, containing a fair amount of non-reusable components, or 3) we simply prefer to have all files related to a specific route being co-located within the same folder.
If you think about it, we use encapsulation and co-location in day-to-day code:
- we usually define variables close to their usage to provide a hint related to the scope of the variable;
- we create local functions or private methods when they are not needed elsewhere, being encapsulated and co-located within the class or module that uses them;
- most CSS-in-JS libraries apply the same principle to styles, co-locating them within the component that uses those styles.
Since files and folders are just another way to organize our code, we can also apply the encapsulation approach when deciding how to organize our components.