Introduction
Next.js has become increasingly popular in the React ecosystem in recent years. It is now the main 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, use of CSS modules for styling, 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.
Setting up a Next.js project
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
tsx
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
tsx
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:
Ensuring engine compatibility and stability at scale
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 v16:
16.0.0
tsx
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
tsx
Now, go to the package.json file and add a new key-value pair:
"engines": {
"node": ">=16.0.0",
"npm": "please-use-npm"
},
tsx
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.
Setting up version control
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"
tsx
For the very first commit, you might need to push it to the remote repository like so:
git remote add origin git@github.com:<YOUR_GITHUB_USERNAME>/nextjs-architecture.git
git branch -M main
git push -u origin main
tsx
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.
Enforcing code formatting rules
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"
}
}
tsx
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
tsx
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
}
tsx
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;
tsx
Now, try changing anything in your code from a single quotation mark to a double quotation mark, as in the following example:
'Hello' => "Hello"
tsx
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 ."
}
tsx
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.
Structuring your Next.js directory
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:
> pages
> components
> utils
> hooks
tsx
The pages 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.
Setting up a custom document file
When you generate a Next.js boilerplate code, it utilizes a document file that is responsible for the actual script to run. When your application grows more complex, it is a good idea to override the document file with your own custom document file.
To do so, just create a file called _document.tsx — including the underscore — under your pages directory and paste the following code:
import Document, { Head, Html, Main, NextScript } from 'next/document';
class MyDocument extends Document {
render() {
return (
<Html>
<Head>
<link rel='preconnect' href='https://fonts.googleapis.com' />
<link rel='preconnect' href='https://fonts.gstatic.com' />
<link
href='https://fonts.googleapis.com/css2?family=Rubik:wght@400;500;700&display=swap'
rel='stylesheet'
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
export default MyDocument;
tsx
This custom document file is a good place to include links to font files, analytics SDK links, or any script that you think should be available globally such as _Main _ and NextScript.
Setting up a custom layout
If you are using Next.js v13 or newer, you already have out-of-the-box support for a layout file. Just create a file called layout.tsx under the app folder and write the following code:
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang='en'>
<body>
<Navbar />
{children}
<Footer />
</body>
</html>
);
}
tsx
This file is on top of all your routes, so you can put shared components in it — such as Navbar or Footer — that would appear in every route.
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>
)
}
tsx
Then, import the Layout /> component at the root level — i.e., index.tsx or _app.tsx — in your app:
<Layout>
<App />
</Layout>
tsx
This Layout file would behave similarly and import your shared components to all your routes.
Integrating Next.js with Storybook
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
tsx
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"
}
}
tsx
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"
}
tsx
Add the following to the 8main.js* file under the *.storybook* folder as well:
module.exports = {
typescript: { reactDocgen: "react-docgen" },
...
}
tsx
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"
tsx
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
tsx
React components such as these can be tested, visualized, and integrated further into your application under the pages folder.
Deploying your Next.js application
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 is the most straightforward for Jamstack applications.
Sign up for Vercel and set 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:
Project name: nextjs-architecture Build Command: npm run build Install Command: npm install Root Directory: ./