Open9

【Next.js】App Routerが出てきた今、もう一度環境構築について一考する

airRnotairRnot

プロジェクトの作成

今回はpnpmでプロジェクトを作成します

mkdir next-app-router-template
cd next-app-router-template
pnpm dlx create-next-app .
✔ Would you like to use TypeScript with this project? … Yes
✔ Would you like to use ESLint with this project? … Yes
✔ Would you like to use Tailwind CSS with this project? … Yes
✔ Would you like to use `src/` directory with this project? … Yes
✔ Use App Router (recommended)? … Yes
✔ Would you like to customize the default import alias? … Yes
✔ What import alias would you like configured? … @/*

オプションはすべてYesにします

airRnotairRnot

tsconfigの設定

規約はガチガチにしたいので、@tsconfig/strictestを導入します

pnpm add -D @tsconfig/strictest
tsconfig.json
{
+ "extends": "@tsconfig/strictest/tsconfig.json",
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

airRnotairRnot

拡張子の変更

tailwind.config.jspostcss.config.jsnext.config.jsといった設定ファイルの拡張子をcjsに変更します。ファイルは CommonJS モジュールです。ES モジュールに変換される可能性があります。というエラーが鬱陶しいからです。ついでに.eslintrc.json.eslintrc.cjsに変更します。

.eslintrc.cjs
module.exports = {
  extends: ["next/core-web-vitals"],
};

airRnotairRnot

ESLint & Prettier

必要なライブラリをインストール

pnpm add -D eslint-config-airbnb eslint-plugin-jsx-a11y @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-import eslint-plugin-unused-imports eslint-config-prettier prettier

.prettierrcを追加

.prettierrc
{
  "singleQuote": true,
  "semi": true,
  "tabWidth": 2,
  "quoteProps": "consistent",
  "trailingComma": "es5",
  "jsxSingleQuote": false,
  "bracketSpacing": true,
  "arrowParens": "always"
}

.eslintrc.cjsを編集

.eslintrc.cjs
module.exports = {
  root: true,
  extends: [
    'eslint:recommended',
    'airbnb',
    'airbnb/hooks',
    'plugin:@typescript-eslint/recommended',
    'plugin:@typescript-eslint/recommended-requiring-type-checking',
    'plugin:jsx-a11y/recommended',
    'next/core-web-vitals',
    'prettier',
  ],
  plugins: ['import', 'unused-imports', 'jsx-a11y', '@typescript-eslint'],
  parser: '@typescript-eslint/parser',
  parserOptions: {
    project: './tsconfig.json',
  },
  rules: {
    /* eslint */
    'arrow-body-style': 'off',
    /* typescript */
    '@typescript-eslint/no-unused-vars': [
      'warn',
      {
        argsIgnorePattern: '^_',
        varsIgnorePattern: '^_',
        caughtErrorsIgnorePattern: '^_',
        destructuredArrayIgnorePattern: '^_',
      },
    ],
    /* react */
    'react/jsx-filename-extension': [
      'error',
      { extensions: ['.js', '.jsx', 'ts', 'tsx'] },
    ],
    'react/jsx-uses-react': 'off',
    'react/react-in-jsx-scope': 'off',
    'react/function-component-definition': 'off',
    /* import */
    'unused-imports/no-unused-imports': 'error',
    'import/order': [
      'error',
      {
        'groups': [
          'builtin',
          'external',
          'parent',
          'sibling',
          'index',
          'object',
          'type',
        ],
        'pathGroupsExcludedImportTypes': ['builtin'],
        'newlines-between': 'always',
        'pathGroups': [
          {
            pattern: '{react,react-dom/**,react-router-dom}',
            group: 'builtin',
            position: 'before',
          },
          {
            pattern: '@/app/**',
            group: 'parent',
            position: 'before',
          },
          {
            pattern: '@/components/**',
            group: 'parent',
            position: 'before',
          },
          {
            pattern: '@/stores/**',
            group: 'parent',
            position: 'before',
          },
          {
            pattern: '@/providers/**',
            group: 'parent',
            position: 'before',
          },
          {
            pattern: '@/hooks/**',
            group: 'parent',
            position: 'before',
          },
          {
            pattern: '@/constants/**',
            group: 'parent',
            position: 'before',
          },
          {
            pattern: '@/libs/**',
            group: 'parent',
            position: 'before',
          },
          {
            pattern: '@/utils/**',
            group: 'parent',
            position: 'before',
          },
          {
            pattern: '@/**',
            group: 'parent',
            position: 'before',
          },
        ],
        'alphabetize': {
          order: 'asc',
        },
      },
    ],
    '@typescript-eslint/consistent-type-imports': [
      'error',
      {
        prefer: 'type-imports',
      },
    ],
    'no-restricted-imports': [
      'error',
      {
        patterns: ['./*', '../*', '~/*', '~~/*'],
      },
    ],
  },
};

.eslintignoreを追加

# config
.eslintrc.cjs
.prettierrc
next.config.cjs
tailwind.config.cjs
tsconfig.json
postcss.config.cjs

# build
build/
bin/
obj/
out/
.next/

参考

https://zenn.dev/resistance_gowy/articles/91b4f62b9f48ec

airRnotairRnot

Linterの設定を追加

package.json
{
  "name": "next-app-router-template",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
-   "lint": "next lint"
+   "lint": "next lint",
+   "eslint:fix": "eslint src --ext .js,jsx,.ts,.tsx --fix",
+   "prettier:fix": "prettier --check --write 'src/**/*.{js,jsx,ts,tsx,css,scss,md,mdx}'",
+   "format": "pnpm run eslint:fix && pnpm run prettier:fix"
  },
  "dependencies": {
    "@types/node": "20.2.5",
    "@types/react": "18.2.8",
    "@types/react-dom": "18.2.4",
    "autoprefixer": "10.4.14",
    "eslint": "8.42.0",
    "eslint-config-next": "13.4.4",
    "next": "13.4.4",
    "postcss": "8.4.24",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "tailwindcss": "3.3.2",
    "typescript": "5.1.3"
  },
  "devDependencies": {
    "@tsconfig/strictest": "^2.0.1",
    "@typescript-eslint/eslint-plugin": "^5.59.8",
    "@typescript-eslint/parser": "^5.59.8",
    "eslint-config-airbnb": "^19.0.4",
    "eslint-config-prettier": "^8.8.0",
    "eslint-plugin-import": "^2.27.5",
    "eslint-plugin-jsx-a11y": "^6.7.1",
    "eslint-plugin-unused-imports": "^2.0.0",
    "prettier": "^2.8.8"
  }
}

.vscode/settings.json
{
  "editor.formatOnSave": true,

  "[javascript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[json]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },

  "eslint.validate": [
    "javascript",
    "javascriptreact",
    "typescript",
    "typescriptreact"
  ],

  "editor.codeActionsOnSave": {
    "source.fixAll.stylelint": true,
    "source.fixAll.eslint": true,
    "source.organizeImports": false
  },

  "css.validate": false,
  "scss.validate": false,
  "javascript.format.enable": false,
  "typescript.format.enable": false
}

airRnotairRnot

Markuplintの設定

pnpm dlx markuplint --init
✔ Which do you use template engines? · React (JSX)

✔ May I install them automatically? (y/N) · false

✔ Do you customize rules? (y/N) · false
✔ Does it import the recommended config? (y/N) · true
pnpm add -D markuplint @markuplint/jsx-parser @markuplint/react-spec
package.json
{
  "name": "next-app-router-template",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "eslint:fix": "eslint src --ext .js,jsx,.ts,.tsx --fix",
    "prettier:fix": "prettier --check --write 'src/**/*.{js,jsx,ts,tsx,css,scss,md,mdx}'",
-   "format": "pnpm run eslint:fix && pnpm run prettier:fix"
+   "format": "pnpm run eslint:fix && pnpm run prettier:fix",
+   "html:lint": "markuplint src/**/*.{html,jsx,tsx}"
  },
  "dependencies": {
    "@types/node": "20.2.5",
    "@types/react": "18.2.8",
    "@types/react-dom": "18.2.4",
    "autoprefixer": "10.4.14",
    "eslint": "8.42.0",
    "eslint-config-next": "13.4.4",
    "next": "13.4.4",
    "postcss": "8.4.24",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "tailwindcss": "3.3.2",
    "typescript": "5.1.3"
  },
  "devDependencies": {
    "@markuplint/jsx-parser": "^3.7.0",
    "@markuplint/react-spec": "^3.8.0",
    "@tsconfig/strictest": "^2.0.1",
    "@typescript-eslint/eslint-plugin": "^5.59.8",
    "@typescript-eslint/parser": "^5.59.8",
    "eslint-config-airbnb": "^19.0.4",
    "eslint-config-prettier": "^8.8.0",
    "eslint-plugin-import": "^2.27.5",
    "eslint-plugin-jsx-a11y": "^6.7.1",
    "eslint-plugin-unused-imports": "^2.0.0",
    "markuplint": "^3.10.0",
    "prettier": "^2.8.8"
  }
}

airRnotairRnot

TailwindCSSの設定

.vscode/settings.json
{
  "editor.formatOnSave": true,

  "[javascript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[json]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },

  "eslint.validate": [
    "javascript",
    "javascriptreact",
    "typescript",
    "typescriptreact"
  ],

  "editor.codeActionsOnSave": {
    "source.fixAll.stylelint": true,
    "source.fixAll.eslint": true,
    "source.organizeImports": false
  },

  "css.validate": false,
  "scss.validate": false,
  "javascript.format.enable": false,
- "typescript.format.enable": false
+ "typescript.format.enable": false,
+
+ "files.associations": {
+   "*.css": "tailwindcss"
+ }
}

tailwind.config.cjs
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
    './src/components/**/*.{js,ts,jsx,tsx,mdx}',
    './src/app/**/*.{js,ts,jsx,tsx,mdx}',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};

src/app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

linter用のライブラリをインストール

pnpm add -D eslint-plugin-tailwindcss prettier-plugin-tailwindcss
.eslintrc.cjs
module.exports = {
  root: true,
  extends: [
    'eslint:recommended',
    'airbnb',
    'airbnb/hooks',
    'plugin:@typescript-eslint/recommended',
    'plugin:@typescript-eslint/recommended-requiring-type-checking',
    'plugin:jsx-a11y/recommended',
    'next/core-web-vitals',
+   'plugin:tailwindcss/recommended',
    'prettier',
  ],
- plugins: ['import', 'unused-imports', 'jsx-a11y', '@typescript-eslint'],
+ plugins: [
+   'import',
+   'unused-imports',
+   'jsx-a11y',
+   '@typescript-eslint',
+   'tailwindcss',
+ ],
  parser: '@typescript-eslint/parser',
  parserOptions: {
    project: './tsconfig.json',
  },
  rules: {
    /* eslint */
    'arrow-body-style': 'off',
    /* typescript */
    '@typescript-eslint/no-unused-vars': [
      'warn',
      {
        argsIgnorePattern: '^_',
        varsIgnorePattern: '^_',
        caughtErrorsIgnorePattern: '^_',
        destructuredArrayIgnorePattern: '^_',
      },
    ],
    /* react */
    'react/jsx-filename-extension': [
      'error',
      { extensions: ['.js', '.jsx', 'ts', 'tsx'] },
    ],
    'react/jsx-uses-react': 'off',
    'react/react-in-jsx-scope': 'off',
    'react/function-component-definition': 'off',
    /* import */
    'unused-imports/no-unused-imports': 'error',
    'import/order': [
      'error',
      {
        'groups': [
          'builtin',
          'external',
          'parent',
          'sibling',
          'index',
          'object',
          'type',
        ],
        'pathGroupsExcludedImportTypes': ['builtin'],
        'newlines-between': 'always',
        'pathGroups': [
          {
            pattern: '{react,react-dom/**,react-router-dom}',
            group: 'builtin',
            position: 'before',
          },
          {
            pattern: '@/app/**',
            group: 'parent',
            position: 'before',
          },
          {
            pattern: '@/components/**',
            group: 'parent',
            position: 'before',
          },
          {
            pattern: '@/stores/**',
            group: 'parent',
            position: 'before',
          },
          {
            pattern: '@/providers/**',
            group: 'parent',
            position: 'before',
          },
          {
            pattern: '@/hooks/**',
            group: 'parent',
            position: 'before',
          },
          {
            pattern: '@/constants/**',
            group: 'parent',
            position: 'before',
          },
          {
            pattern: '@/libs/**',
            group: 'parent',
            position: 'before',
          },
          {
            pattern: '@/utils/**',
            group: 'parent',
            position: 'before',
          },
          {
            pattern: '@/**',
            group: 'parent',
            position: 'before',
          },
        ],
        'alphabetize': {
          order: 'asc',
        },
      },
    ],
    '@typescript-eslint/consistent-type-imports': [
      'error',
      {
        prefer: 'type-imports',
      },
    ],
    'no-restricted-imports': [
      'error',
      {
        patterns: ['./*', '../*', '~/*', '~~/*'],
      },
    ],
+   /* tailwindcss */
+   'tailwindcss/no-custom-classname': [
+     'warn',
+     {
+       config: 'tailwind.config.cjs',
+     },
+   ],
+   'tailwindcss/classnames-order': 'off',
  },
};

airRnotairRnot

Jest

pnpm add -D jest jest-environment-jsdom @testing-library/jest-dom @testing-library/react @testing-library/user-event @types/testing-library__jest-dom

設定ファイルの作成

jest.config.cjs
const nextJest = require('next/jest')

const createJestConfig = nextJest({
  // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
  dir: './',
})

// Add any custom config to be passed to Jest
const customJestConfig = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  testEnvironment: 'jest-environment-jsdom',
}

// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig)

jest.setup.js
// Optional: configure or set up a testing framework before each test.
// If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js`

// Used for __tests__/testing-library.js
// Learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect';

package.json
{
  "name": "next-app-router-template",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "eslint:fix": "eslint src --ext .js,jsx,.ts,.tsx --fix",
    "prettier:fix": "prettier --check --write 'src/**/*.{js,jsx,ts,tsx,css,scss,md,mdx}'",
    "format": "pnpm run eslint:fix && pnpm run prettier:fix",
-   "html:lint": "markuplint src/**/*.{html,jsx,tsx}"
+   "html:lint": "markuplint src/**/*.{html,jsx,tsx}",
+   "test": "jest --watch",
+   "test:ci": "jest --ci"
  },
  "dependencies": {
    "@types/node": "20.2.5",
    "@types/react": "18.2.8",
    "@types/react-dom": "18.2.4",
    "autoprefixer": "10.4.14",
    "daisyui": "^3.0.3",
    "eslint": "8.42.0",
    "eslint-config-next": "13.4.4",
    "next": "13.4.4",
    "postcss": "8.4.24",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "tailwindcss": "3.3.2",
    "typescript": "5.1.3"
  },
  "devDependencies": {
    "@markuplint/jsx-parser": "^3.7.0",
    "@markuplint/react-spec": "^3.8.0",
    "@testing-library/jest-dom": "^5.16.5",
    "@testing-library/react": "^14.0.0",
    "@testing-library/user-event": "^14.4.3",
    "@tsconfig/strictest": "^2.0.1",
    "@types/testing-library__jest-dom": "^5.14.6",
    "@typescript-eslint/eslint-plugin": "^5.59.8",
    "@typescript-eslint/parser": "^5.59.8",
    "eslint-config-airbnb": "^19.0.4",
    "eslint-config-prettier": "^8.8.0",
    "eslint-plugin-import": "^2.27.5",
    "eslint-plugin-jsx-a11y": "^6.7.1",
    "eslint-plugin-tailwindcss": "^3.12.1",
    "eslint-plugin-unused-imports": "^2.0.0",
    "jest": "^29.5.0",
    "jest-environment-jsdom": "^29.5.0",
    "markuplint": "^3.10.0",
    "prettier": "^2.8.8",
    "prettier-plugin-tailwindcss": "^0.3.0"
  }
}

airRnotairRnot

Storybook

pnpm dlx storybook@latest init
.eslintrc.cjs
module.exports = {
  root: true,
  extends: [
    'eslint:recommended',
    'airbnb',
    'airbnb/hooks',
    'plugin:@typescript-eslint/recommended',
    'plugin:@typescript-eslint/recommended-requiring-type-checking',
    'plugin:jsx-a11y/recommended',
    'next/core-web-vitals',
    'plugin:tailwindcss/recommended',
+   'plugin:storybook/recommended',
    'prettier',
  ],
  ...
};

設定ファイルの編集

.storybook/preview.ts
import type { Preview } from '@storybook/react';
import '../src/app/globals.css';

const preview: Preview = {
  parameters: {
    actions: { argTypesRegex: '^on[A-Z].*' },
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/,
      },
    },
  },
};

export default preview;