Set up linting and formatting for code and (S)CSS in Next.js with ESLint, Stylelint, Prettier and lint-staged
Tags: nextjs, eslint, stylelint, prettier
Published: 2023-04-26 21:20:21

Next.js CLI generates a really nice, minimal app scaffold with the production features that it is famous for, including a production-grade Webpack config, static site generation and support for SSR and SEO. However, the scaffold lacks a proper linting and code formatting setup.

In this post I'll show you how I configure linting and formatting in my Next.js projects. Setup shown is for a TypeScript app. However all of the steps would remain the same for a Javascript app except for one change that I'll point out in due course.

I set up the following:

* **[Prettier for code formatting](#prettier-setup)** This ensures that your codebase is consistently formatted. Prettier can format pretty much any file (`.html`, `.json`, `.js`, `.ts`, `.css`, `.scss` etc.).

* **[ESLint for linting code files](#eslint-setup)**: ESLint lints code files (Javascript and Typescript) for errors and problems and enforces coding style.

* **[Stylelint for linting stylesheets](#stylelint-setup)** Stylelint lints stylesheets (CSS and SCSS etc.) for errors and problems, including use of deprecated syntax and enforces coding style.

* **[Scripts in `package.json`](#set-up-raw-packagejson-endraw-scripts)** The most imporant thing here is to ensure that linters are part the `"build"` script that is used to build the app locally and in CI/CD pipelines (including when deploying to Vercel, the company behind Next.js).

* **[lint-staged to run linters and code formatter on staged files](#lintstaged-setup)** in a Git pre-commit hook so that linters and formatter automatically run whenever we execute `git commit`.

    This is a **fast failsafe** to ensure code doesn't get committed if it fails linting rules and that when it does get committed, it is consistently formatted.

    Running linters only on staged files (those that have been added to Git index using `git add`) is much faster than processing all files in the working directory. 

* **[VS Code extensions](#vs-code-extensions-setup)** to run Prettier, ESLint and Stylelint as you edit files. You can see any linting errors in real time via syntax highlighting and the code gets formatted properly as soon as you have made your changes. This is very useful if you use VS Code.

**To follow along**, generate a Next.js app using `npx create-next-app myapp --eslint --typescript`. Then open up the new app in your code editor and proceed as follows.

## Prettier setup

[Prettier](https://prettier.io/) is an opinionated code formatter that can format pretty much any file (`.html`, `.json`, `.js`, `.ts`, `.css`, `.scss` etc.).

Set it up as follows:

1. Install Prettier:

    ```bash
    npm install --save-dev prettier
    ```

2. Create `.prettierrc.json` in project root. Mine has the following content (the defaults for all other settings work fine for me):

    ```json
    {
      "singleQuote": true,
      "jsxSingleQuote": true
    }
    ```

3. Create `.prettierignore`

    ```
    node_modules
    .next
    .husky
    coverage
    .prettierignore
    .stylelintignore
    .eslintignore
    ```

    This file ensure that files which are not app code (i.e. which are not `.js`, `.ts`, `.css` files etc.) do not get formatted, otherwise Prettier would end up spending too much time processing files whose formatting you don't really care about.

    `'prettierignore` (the file we just created), `.eslintignore` and `.stylelintignore` have been ignored because these are plain text files with no structure so Prettier would complain that it cannot format them.

## ESLint setup

ESLint is the linter for JavaScript/TypeScript code.

This is the ESLint config that is generated by Next.js CLI:

```json
{
  "extends": "next/core-web-vitals"
}
```

**If your app uses JavaScript then the default ESLint config is fine and you can skip the rest of this section**.

**Before we set up ESLint for use in a TypeScript app**, note the following:

* In `tsconfig.json` in app root, `compilerOptions.strict` is already set to `true`:

    ```json
    {
      "compilerOptions": {
        "target": "es5",
        "lib": ["dom", "dom.iterable", "esnext"],
        "allowJs": true,
        "skipLibCheck": true,
        strict: true,
        ...
    ```
    
    This enables TypeScript's strict code checking. This means that if we run `npm run build` (the "build" script is defined as `next build` in `package.json`), the command would fail if there's an error in the TypeScript code under a strict enforcement of TypeScript's type safety rules. 

    However, `npm run lint` (the "lint" script is defined as `next lint` in `package.json`, which in turn runs ESLint) would still succeed. This happens because as configured by Next.js CLI, ESLint does not enforce TypeScript's strict type safety rules.

    This is an issue, not least because linting staged files with `lint-staged` when committing code to Git would not fail even if there are strict type safety errors in it, and so the code would get checked in.

    Therefore we need to modify ESLint config to enforce TypeScript's strict checking during linting also.

* Since Next v13, scaffolded projects are configured with a TypeScript Plugin demonstrated in [this video](https://www.youtube.com/watch?v=pqMqn9fKEf8). However, **at least as of 05/05/2023**, contrary to [what their docs state](https://nextjs.org/docs/app/building-your-application/configuring/typescript#plugin-features), this provides edit time intellisense and syntax highlighting only. If the rules that the plugin looks out for are violated, neither linting (command `next lint`) nor building (`next build`) fails.

    Therefore this plugin does not remove the need to set up ESLint properly for TypeScript.

**To set up ESLint, proceed as follows:**

1. On terminal in the app directory, run:

    ```bash
    npm install --save-dev eslint eslint-config-prettier @typescript-eslint/eslint-plugin @typescript-eslint/parser
    ```

2. **If you already have a `.eslintrc.json` in app's root folder**, rename it to `.eslintrc.js`.

    Otherwise create a file named `.eslintrc.js` in the root.

3. Replace contents of `eslintrc.js` with the following:

    ```javascript
    /* eslint-env node */
    module.exports = {
      root: true,
      parser: '@typescript-eslint/parser',
      plugins: ['@typescript-eslint'],
      extends: ['eslint:recommended', 'next/core-web-vitals', 'prettier'],
      overrides: [
        {
          files: ['*.ts', '*.tsx'],
          parserOptions: {
            project: ['./tsconfig.json'],
            tsconfigRootDir: __dirname,
          },
          extends: [
            'plugin:@typescript-eslint/recommended',
            'plugin:@typescript-eslint/recommended-requiring-type-checking',

            //declaring 'next/core-web-vitals' and 'prettier' again in case
            //the two plugin:... configs above overrode any of their rules
            //Also, 'prettier' needs to be last in any extends array
            'next/core-web-vitals',
            'prettier',
          ],
        },
      ],
    };
    ```

    **This is what the various lines of this file do**:

    `/* eslint-env node */` stops ESLint from complaining that this is a CommonJS module. We have had to put this in because ESLint, as we have configured it here, does not allow CommonJS modules (which `.eslintrc.js` is, see `module.exports = ...` at the top) in a TypeScript app and expects modules in the project to be ES6.

    `root: true` says this is the topmost eslint configuration file although there may be nested eslint configs in subfolders.

    `parser: '@typescript-eslint/parser'` specifies the ESLint Typescript parser to be used instead of the default Espree parser. 

    `parserOptions:` tells the parser where to find the `tsconfig.json` file.

    `extends:` specifies a number of ESLint configurations, each of which is a bundle of linting rules. A rule in a config further down the array (further to the right) overrides the same rule if declared in any config to the left.

    Config `'next/core-web-vitals'` specifies Next.js-specific rules (this was the only config in the original .eslintrc.json generated by the Next.js CLI). 

    Config `prettier` is provided by the package [eslint-config-prettier](https://www.npmjs.com/package/eslint-config-prettier) that we installed earlier and switches off those rules in ESLint that conflict with the code formatting done by Prettier. **This must be the last config in `extends:`**.

    `plugins: ['@typescript-eslint'],` specifies the plugin which provides the parser as well as two of the configs that provide Typescript-specific linting rules.

    The `overrides` section ensures that strict Typescript linting rules only apply to files with `.ts` and `.tsx` extensions (from [this excellent StackOverflow answer](https://stackoverflow.com/questions/58510287/parseroptions-project-has-been-set-for-typescript-eslint-parser)). Otherwise, if we had put the configs in `extends` that are currently in the override in the root `extends` instead, they would apply to `.js` files also. Then running ESLint on the project would throw errors on `.js` files if they don't meet strict Typescript rules. This would be a problem as we have several `.js` config files including the `.eslintrc.js` itself, so there would be linting errors. These errors are avoided by the use of the override.

4. Create `.eslintignore` in the project root. It doesn't need to have any content for now:

    ```
    
    ```

    but would come in handy in the future if ever you need to add folders or files that should be ignored by ESLint (see the final section of this post for an example). 

## Stylelint setup

Stylelint is a linter for CSS and SCSS stylesheets (as well as for stylesheets in other formats).

The setup given below would work for both CSS and SCSS files:

1. On the terminal in project root:

    ```bash
    npm install --save-dev sass
    ```

    Next.js has [built-in SASS/SCSS support](https://nextjs.org/docs/basic-features/built-in-css-support#sass-support) (so the webpack config knows how to handle `.scss` and `.sass` files). However, you still need to install a version of `sass` package yourself, which is what we did above.

2. Install packages for Stylelint and its rule configs:

    ```bash
    npm install --save-dev stylelint stylelint-config-standard-scss stylelint-config-prettier-scss
    ```

    Of these three packages:

    [`stylelint`](https://stylelint.io/) is the linter.

    [`stylelint-config-standard-scss`](https://github.com/stylelint-scss/stylelint-config-standard-scss) is a Stylelint config that provides linting rules. It uses the Stylelint plugin [stylelint-css](https://www.npmjs.com/package/stylelint-scss) and extends configs [stylelint-config-standard](https://github.com/stylelint/stylelint-config-standard) which defines rules for vanilla CSS, and [stylelint-config-recommended-scss](https://github.com/stylelint-scss/stylelint-config-recommended-scss) which defines SCSS specific rules. As a result extending from this one config is enough to get linting support for both CSS and SCSS files.

    [`stylelint-config-prettier-scss`](https://www.npmjs.com/package/stylelint-config-prettier-scss) extends [stylelint-config-prettier](https://www.npmjs.com/package/stylelint-config-prettier) and turns off those Stylint rules that conflict with Prettier's code formatting. This should be declared last in `extends:` array in `.stylelintrc.json` (as shown below).

3. Create `.stylelintrc.json` in project root with the following contents:

    ```json
    {
      "extends": [
        "stylelint-config-standard-scss",
        "stylelint-config-prettier-scss"
      ],
      "rules": {
        "selector-class-pattern": null
      }
    }
    ```

    `"extends"` section declares the two Stylelint configs whose NPM packages we installed in the previous step. 

    `"rules"` section is used to configure stylints rules. Here you can turn on or off, or configure behavior of, individual Stylelint rules. A rule can be turned off by setting it to `null`, as I have done for `"selector-class-pattern"`. I turned it off because it insists on having CSS classes in the so called _kebab case_ e.g. `.panel-quiz` instead of `.panelQuiz`. I find it inconvenient for various reasons so I turned it off.

4. Create `.stylelintignore` in the project root with the following contents:

    ```json
    styles/globals.css
    styles/Home.module.css
    coverage
    ```

    I create this file so that the two stylesheets generated by Next.js CLI which do not comply with the linting rules can get ignored (there might be a better way of doing this but this works for me). Also, files in `coverage` folder do not need to be linted and would likely throw up errors.

## Set up `package.json` scripts

1. The most important script is `"build"`. The default command for this script, `next build`, runs ESLint but not Stylelint. So modify it in `package.json` file as follows:

    ```json
    {
      "scripts": {
        "build": "prettier --check . && stylelint --allow-empty-input \"**/*.{css,scss}\" && next build",
        ...
    ```

    Using this tweak, we can run `npm run build` locally or in a CI/CD pipeline and it would fail not only on ESLint failure but now also on Stylelint failure. Indeed if you [deploy your app to Vercel](https://nextjs.org/learn/basics/deploying-nextjs-app/deploy), the default build logic there also calls `npm run build`. So when I introduced an error in one of my stylesheets, then deployed to Vercel, I got the following error during deployment:

    ![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/om6228598f4pvb2iuwj9.png)

    Notice we have added **`prettier --check .`** at the front also. This means if a file is not formatted as per our Prettier setup then too the `build` script would fail. This should not happen ordinarily (see lint-staged setup below) but is a failsafe to prevent inconsistently formatted files from creeping into the codebase. If there are exceptions you want to allow, you can always mark those using special comments that Prettier understands (discussed below).

2. Set up `"lint"` and `"format"` scripts in `package.json`. These allow formatting and linting on the command line and come in handy every now and then:

    ```json
    {
      "scripts": {
        ...
        "lint": "prettier --check . && stylelint --allow-empty-input \"**/*.{css,scss}\" && next lint",
        "format": "prettier --write ."
    ```

3. I recommend setting up a `build:local` script as follows:

    ```json
    "build:local": "prettier --write . && stylelint --allow-empty-input \"**/*.{css,scss}\" && next build",
    ```

    The only difference between this and the `build` script above is that this formats the code before linting and building it instead of just `--check`ing the formatting. This is really useful when coding because tools such as command line tools and VS Code extensions frequently generate or alter code so that formatting is no longer consistent the Prettier ruleset. When this happens, it is quite frustrating to run `npm run build` only to find out that Prettier has failed because some files require formatting.

    **If locally, during development, we run `npm run build:local` instead of `npm run build`**, any files that need formatting would first get formatted before lint and build takes place. Therefore the build will never fail because of a formatting issue in a file that we did not (manually) introduce.

    The reason why I have separated this from `build` script is because `build` is used on Vercel during remote build and deployment process. For this reason I use `npm run build` in my own CI/CD pipelines on GitHub Actions and Azure DevOps also. Hence the need to define another command for local use only that actually formats files instead of only checking the formatting.
    

## lint-staged setup

[lint-staged](https://github.com/okonet/lint-staged) is a package that can be used to run formatting and linting commands on staged files in a Git repo. Staged files are those that have been added to the Git index using `git add .`, these are the files that have changed since last commit and will get committed when you next run `git commit`.

[Husky](https://github.com/typicode/husky) is the typical choice in Node.js packages for registering commands to run in Git hooks. For example, registering `npx lint-staged` with Husky to run in the Git pre-commit hook means lint-staged will run automatically whenever we execute `git commit` to commit our code. At that time, the formatter (Prettier) and linters (ESLint and Stylelint) that have been configured in lint-staged will run on the staged files. If there are any errors, the commit would fail. 

Whenever `git commit` fails due to linting errors, we can fix those, then run `git add .` and `git commit` again. Thus code only ever gets into the repo after it has been consistently formatted and verified to be free of linting errors. This a particularly big advantage in a team setting.

I prefer to run formatting and linting only on staged files using lint-staged rather than on the entire codebase. This is both much faster (a much smaller number of files needs to be processed) and more useful (there's no point running formatter on files that are in the local directory but which haven't been staged, because any changes introduced by the formatter won't get committed).

Setup for lint-staged and Husky is as follows:

1. Install lint-staged package:

    ```bash
    npm install --save-dev lint-staged
    ```

2. Create `lint-staged.config.js` in project root with the following contents:

    ```javascript
    /* eslint-env node */
    const path = require('path');

    const eslintCommand = (filenames) =>
      `next lint --file ${filenames
        .map((f) => path.relative(process.cwd(), f))
        .join(' --file ')}`;

    const formatCommand = 'prettier --write';
    const stylelintCommand = 'stylelint --allow-empty-input "**/*.{css,scss}"';
    module.exports = {
      '*.{js,jsx,ts,tsx}': [formatCommand, eslintCommand],
      '*.{css,scss}': [formatCommand, stylelintCommand],
      '!*.{js,jsx,ts,tsx,css,scss}': [formatCommand],
    };

    ```

    The above file configures lint-staged to run Prettier, Stylelint and ESLint (`next lint` command invokes ESLint). 

    lint-staged runs all three commands (see the object assigned to `module.exports`) in parallel. Therefore the globs for the commands need to select sets of files that are disjoint (do not overlap). Otherwise there could be a race condition where mutiple commands run on the same file at the same time with indeterminate results.

3. Install Husky NPM package.

    ```bash
    npm install --save-dev husky    
    ```

4. Create and run a `prepare` script to run Husky's own `husky install` command:

    ```bash
    npm pkg set scripts.prepare="husky install"
    npm run prepare
    ```

    The `"prepare"` script is a [lifecycle script](https://docs.npmjs.com/cli/v9/using-npm/scripts#life-cycle-scripts) and one of the conditions under which runs automatically is when `npm install` is run without arguments in the project folder. Defining this script as we have done ensures that if you or a teammate `git fetch`es the repo and then runs `npm install` in the project folder to fetch all its NPM dependencies so that you may start work on the project, then Husky's own `husky install` command would also run. This would set Husky up to run any defined Git hooks when Git operations are performed in the project folder.

    Since the `npm prepare` script hasn't run yet, we run it for the first time to register Husky with the local Git repo, hence the command `npm run prepare` at the end of the snippet above.

5. Register lint-staged with Husky to run at Git pre-commit:

    ```bash
    npx husky add .husky/pre-commit "npx lint-staged"
    ```

## VS Code extensions setup

**If you use VS Code as your code editor:**

1.  Install the following VS Code extensions to provide linting and formatting on file save and syntax highlight on linting errors:

    a. [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint)

    b. [Stylelint extension](https://marketplace.visualstudio.com/items?itemName=stylelint.vscode-stylelint)

    c. [Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)

2. Put the following in a `settings.json` file in `.vscode` folder in the project (you can of course put these settings in you User Preferences file also).

    ```json
    {
      "[javascript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
      },
      "[typescript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
      },
      "[typescriptreact]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
      },
      "[javascriptreact]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
      },
      "[scss]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
      },
      "stylelint.validate": ["css", "scss"],
      "editor.formatOnSave": true,
      "editor.codeActionsOnSave": {
          "source.fixAll": true
      },
    }

    ```

As set up, the extensions would lint and format on Save. 

## Final Checks and Troubleshooting

Build and commit:

```bash
npm run build
git add .
git commit -m "fix: set up linting and formatting" 
```

**Building and committing is a good sanity check** for the setup we just did:

* **If anything had not been set up correctly**, you would likely get errors either during build or at commit. 

* **If you already had some code in the project**, then there might be a few errors when you commit. Typically, these can be resolved by:

    * adding folders or file to one of the `*ignore` files. 

        For example, I already had some code in my project with [Storybook](https://storybook.js.org/) installed. So I had to add folders `.storybook` and `storybook-static` to each of `.stylelintignore`, `.eslintignore` and `.prettierignore` as all three tools complained about them.
        ```
        stories
        storybook-static
        ```

    * Adding plugins for specific file types.

        For example, I had Gherkin `.feature` files in my project to describe integration tests. Prettier couldn't format these. So I added the [prettier-plugin-gherkin](https://www.npmjs.com/package/prettier-plugin-gherkin?activeTab=readme) by simply running:

        ```
        npm install  prettier-plugin-gherkin --save-dev
        ```

        Note that [usually it is enough to install the package for a Prettier plugin](https://prettier.io/docs/en/plugins.html) for Prettier to locate it and additional configuration is not required.

        Likewise, ESLint complained when it encountered  `.cy.ts` files containing Cypress interaction tests for my app. To resolve this linting error, I installed the NPM package for Cypress ESLint plugin and configured it [as described here](https://github.com/cypress-io/eslint-plugin-cypress#installation) (unlike Prettier, to get this ESLint package to work, some configuration was required).

    * Sometimes it is safe to turn off a linting rule at a specific line or for a whole file. While I am always wary of doing this, in a (deliberately bad) experimental code file, I had many instances of an error that VS Code ESLint extension pointed. This was not caught before but was now being pointed out because strict TypeScript linting rules had been enabled:
        
        ![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sg2ne9v2w8dbquv9ytad.png)

        So I pressed **Ctrl + .** to **Show Code Actions** (I could instead have clicked the yellow lighbulb icon shown next to the issue), then selected **Disable `@typescript/no-non-null-assertion` for the entire file**.
        
        ![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/pqume7pod3jzflvblrus.png)

        This placed the comment `/* eslint-disable @typescript-eslint/no-non-null-assertion */` on top of my file to disable all instances of that particular error within the file:

        ![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1d2kml4o11xpqaenk93b.png)