Editor’s note: This article was updated on 30 January 2024 to provide information related to the App Router introduced in Next.js v13, as well as to provide additional recommendations related to linting, Prettier, ESLint, engine locking, and pre-commit guidelines.
Next.js has become increasingly popular in the React ecosystem in recent years. It is now a de facto framework for building React web applications.
This development framework’s popularity is largely thanks to features like first-class support for a wide range of tools and libraries, the use of CSS modules for styling, the use of TypeScript for type safety, image optimization, and much more.
These features make it possible to build scalable applications — if you know how to structure your Next.js project strategically.
In this article, you will learn how to architect a Next.js application from scratch that will scale up without any issues as your project grows.
You can explore the final project on GitHub or simply follow along as we build it in this tutorial.
Let’s begin by installing a fresh Next.js project. In the terminal, all you have to do is type the following command:
npx create-next-app --typescript nextjs-architecture
This will create a Next.js boilerplate template called nextjs-architecture
. Make sure you choose --typescript
as a flag. Using TypeScript in your Next.js app is always a good idea, as it allows better type safety — something you must have while building a modern, scalable, full-stack app.
In this example, I am using npm as a package manager. You can use yarn/pnpm
according to your needs.
After a successful installation, open nextjs-architecture
in VS Code or any IDE of your choice and run the following:
npm run start
This command will spin up your local development server for nextjs-architecture
. By default, Next.js serves its app on port number 3000. Therefore, go to localhost:3000
to see your boilerplate code running successfully.
This Next.js project is using version 14.1.0
and will look like this on the local server:
If you are building an app for work, it’s likely that multiple team members are involved in your project. Therefore, you have to make sure all of them are on the same Node.js version. Using different versions would cause package version inconsistency and may later cause bugs.
To prevent any issues caused by inconsistencies between different versions of Node, you can add a .nvmrc
file at the root level of your project and add the Node.js version number that you want this project to use. In this case, Node has been set to v18:
18.17.0
You should also configure your package manager — in this case, npm — to strictly manage dependency usage for team members. Create a new file called npmrc
and add the following code:
engine-strict=true
Now, go to the package.json
file and add a new key-value
pair:
"engines": { "node": ">=18.17.0", "npm": "please-use-npm" },
This will ensure your project requires Node.js version 16 and above to run, and in this case, also enforces the use of npm
as a package manager. Installing packages using yarn/pnpm
will throw an error in this project. If you are using Yarn, you can add please-use-yarn
instead.
These measures will help ensure compatibility and stability at scale as your Next.js project grows and changes.
At this point, you have set up some basic boilerplate code for Next.js along with some configuration. Now would be a good time to add a version control system to work with fellow team members on this project.
A version control repository is important for any project, but especially crucial for projects expected to scale. Developers can easily track changes, manage project versions, and revert to previous versions when needed, making it easier to collaborate with team members and maximize application uptime.
We will be using GitHub for this project, but you can also opt for GitLab, Bitbucket, or other platforms to host your project’s version control repository according to your needs.
First, make sure you have the GitHub SHA key added to your local machine. Then, create an empty repository on GitHub and name it nextjs-architecture
.
Now, go to your VS Code terminal and push changes to that empty repo using the following command:
git add . git commit -m "initial commit"
For the very first commit, you might need to push it to the remote repository like so:
git remote add origin [email protected]:<YOUR_GITHUB_USERNAME>/nextjs-architecture.git git branch -M main git push -u origin main
Before you push a commit, ensure you have the .gitignore
file added at the root level in your project. Next.js by default provides one with the boilerplate code.
The .gitignore
file ensures certain folders, such as node_modules
or files containing your security keys or environment variables, don’t accidentally get pushed to the repo. These components get generated on the fly when the app is pushed and deployed to a hosting server in the production environment.
You can also copy the above three commands from GitHub itself once you initialize an empty repo. To check if changes have been pushed successfully, go to your GitHub repo and refresh the page. You should be able to see your changes there.
It’s always a good idea to implement engine locking to ensure your project uses the same version across different environments. This will make your project less error-prone and ensure that it works as expected in both the development and production environments.
Engine locking is more of an npm feature than a Next.js feature, and it works across all npm projects. To enable this feature, all you have to do is specify a key called engines
in your package.json
file:
engines : { "node" : "18.x" }
"18.x"
represents any Node version starting with 18.
You also have to create a config file for npm
called .npmrc
at the root level — the same level as package.json
— and add this line:
engine-strict=true
This will make the Node project strict on the version that you specified earlier in the package.json
file and will throw an error if there is a version mismatch on either the local or the production environment while installing.
Engine locking is a good practice to follow especially when working with a large team and having different environments for testing and production.
Code formatting is very important for maintaining code consistency in your project as it scales. You can enforce a strict set of code formatting rules for team members working on the same project, but on different branches or modules.
To achieve this, first, add eslint
to your project. Luckily, in our case, Next.js comes with built-in support for ESLint, so you simply need to configure it.
Check for a .eslintrc.json
file at the very root level of your project. This file allows you to write eslint
rules in key-value pairs.
You can choose from hundreds of rules and add as many rule sets as you want or need in your project. For example, add the following code to this file:
{ "extends": ["next", "next/core-web-vitals", "eslint:recommended"], "globals": { "React": "readonly" }, "rules": { "no-unused-vars": "warn" } }
In the example above, React
has been added as a global package for this project. By doing so, you are ensuring that React is always defined in functional components and JSX code, even if you haven’t explicitly mentioned import React from 'react'
at the top of the file.
The second rule added here — no-unused-vars
— will warn you if you have a variable defined in your file that is not being used anywhere across the app. This rule can help you with removing unnecessary variable declaration, which happens a lot in team projects.
ESLint does its job pretty well, but when paired with Prettier, it can be even more powerful, providing a consistent coding format for all team members across the organization. You can achieve this by installing the prettier
package to the project like so:
npm i prettier -D
Once the installation has been finished, create two files at the root level — the same level as the eslintrc.json file. These files should be named .prettierrc
and .prettierignore
.
The .prettierrc
file will contain all the Prettier rules that you are introducing in the project. The following code demonstrates a few rules you can add as JSON key-value pairs:
{ "tabWidth": 2, "semi": true, "singleQuote": true }
The .prettierignore
file will contain the names of those files and folders that you do not want Prettier to run and analyze. For example, you would never want to run Prettier on the node_modules
folder, dist
folder, package.json
, and other such files. Therefore, add paths to these files in .prettierignore
like so:
dist node_modules package.json
Now, try changing anything in your code from a single quotation mark to a double quotation mark, as in the following example:
'Hello' => "Hello"
If you run npm run prettier --write
and everything runs correctly, you will see Prettier has formatted all your files — changing double quotation marks into single quotation marks again — because of the rule you defined earlier in the .prettierrc
file.
Running this command every time can be cumbersome, so it’s better to put this in your package.json
file as a script:
"scripts: { "prettier": "prettier --write ." }
Now, all you have to do is type npm run prettier
. You can also configure your VS Code to run Prettier whenever you hit Cmd + S
.
With all this set up, now would be a good time to commit changes to your repo. Make sure to follow proper naming conventions while committing changes. Conventional Commits provides a helpful resource you can follow while handling Git naming conventions.
You can now dive into deciding how you want to architect your application code. Neither React nor Next.js have, in general, an opinion as to how you should structure your app. However, since Next.js has file-based routing, you should structure your app similarly.
You can begin by creating a directory structure as follows:
src > > app > components > utils > hooks
The app
folder will be responsible for creating file-based routing in this application.
The components
folder can be used to create React-based component files, such as card components, sliders, tabs, and more.
The utils
folder can be used for a variety of things, such as reusable library instances, some dummy data, or reusable utility functions.
Finally, in the hooks
folder, you can create any custom React Hooks that you might need and which React doesn’t provide out of the box.
Note that creating all the subfolders inside the src
folder is optional. You can skip this altogether and instead add all the subfolders as folders in the root location. While installing a fresh Next.js app, it does ask you if you want a src
based structure — this decision is up to your personal preference.
In the App Router in Next.js v13 or greater, you already have out-of-the-box support for a layout file. You might already have a file called layout.tsx
under src > app > layout.tsx
. This is similar to the _document.tsx
that was present in the pages
directory in earlier Next.js versions.
You can put any components that will be shared across the application — such as Navbar
, Footer
, and others — in this layout.tsx
file. You can also put your global fonts or even wrap the entire app with a provider if you’re using a third-party state management library such as TanStack Query:
import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; const inter = Inter({ subsets: ["latin"] }); export const metadata: Metadata = { title: "Create Next App", description: "Generated by create next app", }; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="en"> <body className={inter.className}>{children}</body> </html> ); }
If you are still on Next.js v12, then you can follow a trick to create a similar layout structure. Create a Layout.tsx
file that wraps the children
component:
function Layout(){ return ( <div> <Navbar /> {children} <Footer /> <div> ) }
Then, import the Layout />
component at the root level — i.e., index.tsx or _app.tsx
— in your app:
<Layout> <App /> </Layout>
This Layout
file would behave similarly and import your shared components to all your routes.
Next.js pairs nicely with the Storybook library, an essential part of building a modern web application. It’s perfect for when you want to visualize a component based on different props and states.
In this project, you can install Storybook like so:
npx sb init --builder webpack5
Based on your project version, you might need to install webpack 5 as a dependency as well.
After a successful installation, you will see a storybook
folder and a stories
folder.
Before you run your stories, you need to tweak .eslintrc.json
to allow it to read Storybook as a plugin:
{ "extends": [ "plugin:storybook/recommended", "next", "next/core-web-vitals", "eslint:recommended" ], "globals": { "React": "readonly" }, "overrides": [ { "files": ["*.stories.@(ts|tsx|js|jsx|mjs|cjs)"] } ], "rules": { "no-unused-vars": "warn" } }
There are a few known issues you might encounter with your Storybook and Next.js integration. You can add the following code in your package.json
file as a workaround for these bugs:
"resolutions": { "@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.cd77847.0" }
Make sure to check the docs for any updates — depending on when you’re reading this tutorial, this bug might have been fixed already, so you might not need to do this.
Add the following to the main.js
file under the .storybook
folder as well:
module.exports = { typescript: { reactDocgen: "react-docgen" }, ... }
Lastly, you can add npm commands to the package.json
file to run your stories quickly:
"storybook": "start-storybook -p 6006", "build-storybook": "build-storybook"
If everything is correct, you can now run stories by running npm run storybook
. This command will open port number 6006
, where you should then see your demo stories:
You can now begin creating individual React components under the components
folder, along with subsequent stories to visualize. For example:
> components > Button > Button.tsx > Button.stories.tsx > button.modules.css
React components such as these can be tested, visualized, and integrated further into your application under the pages
folder.
As your codebase grows, you may have many developers working on it simultaneously. In such cases, it’s of the utmost importance to enforce a set of rules such as code formatting, single quote vs double quote, linting rules, and more to ensure consistency, streamline collaboration, and prevent errors.
This is exactly what a pre-commit hook does: it sets up some rules that will run before you commit your changes to Git. You can have a set of config files that will format your codebase and then only you will be able to commit your changes.
To set up a pre-commit hook with Next.js, you need the following tools:
Next.js already comes with ESLint, and Prettier includes industry best practices. To override them, you can run the following command:
npm i --dev prettier eslint-plugin-prettier eslint-config-prettier
Make sure you have prettier
mentioned in the .eslintrc
config file as the last plugin in the array. Your eslint
config file could be either .eslintrc
or eslintrc.json
based on your preference. The file could look something like this:
{ "extends": ["next", "next/core-web-vitals", "prettier"], "plugins": ["prettier"], "rules": { "prettier/prettier": "warn", "no-console": "warn" } }
Here, you can see a couple of basic rules that will run against your codebase as a pre-commit hook and implement the required changes. Based on your team size and preference, you can create a config file for Prettier like so:
{ "singleQuote": true, "trailingComma": "none", "quoteProps": "as-needed", "bracketSameLine": false, "bracketSpacing": true, "tabWidth": 2 }
The sample rules are pretty self-explanatory — you can add your own based on your needs and preferences.
Next, you can go ahead and install Husky, which will manage the pre-commit hooks:
npm i --dev husky
Now you can go ahead and install the lint-staged
file, which manages the linting for your changes. Create a .lintstagedrc.js
file and add the following snippet:
module.exports = { // Type check TypeScript files '**/*.(ts|tsx)': () => 'yarn tsc --noEmit', // Lint & Prettify TS and JS files '**/*.(ts|tsx|js)': filenames => [ `yarn eslint ${filenames.join(' ')}`, `yarn prettier --write ${filenames.join(' ')}` ], // Prettify only Markdown and JSON files '**/*.(md|json)': filenames => `yarn prettier --write ${filenames.join(' ')}` };
If you look closely, this module is looking for TypeScript and JavaScript files and prettifying them as mentioned in your .prettierrc.js
configuration. This is the check that runs as a pre-commit — before committing to Git.
After setting up, there is one last piece that you need to do: set up a shell file that will run Husky. Create a file at the root as /.husky/pre-commit
and add the following code:
#!/bin/sh . "$(dirname "$0")/_/husky.sh" npm run lint-staged
This sets up your pre-commit. Now, try changing anything in your app to something wrong and committing the changes. Your pre-commit should run and show you an error indicating the line number and file.
Pre-commit hooks are a great practice if you work with a big team. They help you manage formatting and linting rules, as well as make sure everybody is on the same page. Without pre-commit hooks, you may run into many merge conflicts, especially since linters and code formatters work differently on different machines.
Git branching strategies are crucial for effective collaboration and a must for large-scale projects. There are different strategies that you can pick for your project, such as:
Gitflow is the most widely adopted branching strategy. It uses feature-driven branches and multiple primary branches. It’s perfect for projects that have scheduled releases. Here’s a diagram from the Atlassian docs showing how the Gitflow branching strategy works:
Here, the integration branches have the recorded history of the main branch. It serves as a feature branch, which can later be merged for releases.
The Gitflow strategy also uses the hotfix naming convention to further branch out from the feature branch for immediate or priority fixes. The flexibility of Gitflow has made it immensely popular among real-world projects.
In recent years, the trunk-based development workflow is gaining traction as well. Instead of having, for example, many different feature branches or hotfix branches, trunk flow focuses on one main branch only.
This strategy assumes the main branch is always stable and developers can pull from the main branch, work on smaller iterations, and eventually merge with the main branch. It reduces the pain point that comes with the Gitflow strategy of too many merge conflicts due to its exhausting branch strategy.
At the end of the day, the best branching strategy is the one that suits your project and the team in general, making Git collaboration more efficient.
After you have pushed everything to GitHub, the final step would be deploying the Next.js application. You can choose any hosting provider, but we will be using Vercel in this demo project, as it’s the most straightforward option for Jamstack applications.
Sign up for Vercel and set up your account as a Hobby. Make sure to sign up using the GitHub account where you have pushed your project. Once that is done, choose your nextjs-architecture
project from the Vercel dashboard.
Fill in all details as directed, such as:
nextjs-architecture
npm run build
npm install
./
That concludes our demo project! You can check out the complete code on GitHub.
You have now set everything up in this Next.js app to make it easy to scale as your project continues to grow. You can further improve on this architecture by using Git workflow strategies for multiple teams, such as the Git branching strategy we discussed above.
As the Next.js team continues to ship a better version each year, you will likely see more interesting web architecture ideas.
Debugging Next applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Next.js app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.
The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.
Modernize how you debug your Next.js apps — start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
3 Replies to "How to structure scalable Next.js project architecture"
It’s all good stuff, but regarding the file structure and architecture it works until components become a big disorganized lump of everything.
For medium to large projects, I highly recommend Feature-Sliced design: https://feature-sliced.design/
Started using it a few months ago and it feels so much better to work with than just components, lib or hooks folders.
Wow, I literally read through the whole thing, nodding along, because (except I use Cypress) we’re on the same page on everything (also node 20 by now) but got to the “integrate LogRocket” expecting the perfect integration guidance and the article just waved good-bye, jumped in an escape pod, and left me watching a 10 second ad as the pod jettisoned itself and sped away.
Hey Rob! Thanks for the kind words. What you mentioned about the ad is just one of our standard ad things we put at the bottom of articles. Is this what you’re looking for? https://docs.logrocket.com/docs/using-logrocket-with-server-side-rendering <-- If it isn't, let me know, and I can reach out via email to help.