Step-by-Step Next.js
Tue Sep 24 2024
David Bleeker, Founder

There are multiple basic steps required to prepare a repository for a productive developer experience. Before we get there, let's make sure we have the essential steps covered.
Create the new repository
The following instructions assume you are creating a new project called new-app. I would recommend accepting the defaults to all the questions asked.
npx create-next-app@latest new-app
This command installs and configures multiple components, that are all essentials. Don't expect the latest versions to be installed. Rather, Next installs versions known to work together. This is a tremendous time saver in terms of setting up a new project. The tradeoff here is that you won't get the latest bells and whistles that are advertised on the publishers' websites.
The components that are installed and pre-configured are:
- TypeScript
- ESLint
- Tailwind CSS
It also sets up the folder structure according to the Next App Router conventions. If you accepted the default settings then your project layout appears as follows:
├── src
│ └── app
│ ├── favicon.ico
│ ├── fonts
│ │ ├── GeistMonoVF.woff
│ │ └── GeistVF.woff
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── README.md
├── node_modules
│ └── ...
├── next-env.d.ts
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── tailwind.config.ts
└── tsconfig.json
Why use the automated tool?
Why not use a manual script? A manual script gives you fine-grained control over ever aspect of the installation. It also ties you to a tough maintenance task because there are constant updates to hundreds of packages involved, some of which impact the configuration and installation. Unless you are committing to a particular version of everything, or to daily or weekly updates to the script, you will find yourself with an outdated and insecure install script.
The automated tool is used by thousands, making it robust, useful and quick to create a usable baseline from which to work. It is not intended to solve every problem, but to get you up and running as quickly as possible. You can go from zero to a running (albeit skeleton) web application in a few seconds.
Add Testing Support
There are a few essential tools that are missing from the base setup. The repository currently has no testing libraries included, for example. There are good options for both unit testing and end-to-end (E2E) testing. In this article we are going to focus on adding Vitest for unit testing, and Cypress for E2E testing.
Add Vitest
Vitest together with React Testing Library is a popular combination with React developers. It allows flexible testing together with fast execution and automated retesting on change, resulting in a tremendous boost in developer productivity.
Firstly we need to install the required libraries:
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom
Then we need to add the required configuration file:
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
},
})
Finally, we need to add a script to our package.json file.
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "vitest"
}
}
Organize your unit tests
Now all we need to do is to write the unit tests. At this point you have to make a decision: colocate the tests with your code, or create a separate folder to house all the tests, or create a subfolder in each location that holds the tests for that location. There are pros and cons for each approach.
Colocating tests
Organizing tests this way involves placing your test files in the same folder as the source file being tested. For the root page, for example, this would result in the following structure:
├── src
│ └── app
│ ├── ...
│ ├── page.test.tsx
│ └── page.tsx
The advantage is that the test file is near the source file being tested. This can provide a quick view of which files have tests and those that are lacking. The disadvantage is that the number of files in the source folders quickly multiplies, making it more difficult to locate the source files when there are a large number of files present.
Global test folder
The second option creates a folder that houses all the tests for the application:
├── src
│ └── __tests__
│ └── page.test.tsx
│ └── app
│ ├── ...
│ └── page.tsx
This reflects a similar structure to projects in other languages, such as java. The advantage is that tests are housed in their own location away from the source files. The disadvantage is that the distance makes it more difficult to navigate between the source and test file locations.
Note that a popular convention requires that this folder be named __test__. This is not a hard-and-fast requirement. You are free to name it whatever you wish.
Local test folders
Here there is a __test__ subfolder within each folder housing the tests:
├── src
│ └── app
│ ├── ...
│ ├── __tests__
│ │ └── page.test.tsx
│ └── page.tsx
The advantage here is that the tests are located near the source file being tested. This allows you to navigate to the tests when needed without traversing a long distance. The disadvantage is that the separation still makes it difficult to audit which files have tests and which don't.
These options amount to personal preference when the project is small. As the project grows other requirements become more pressing, primarily coordination between developers, and between developers and QA team members.
Add Cypress for E2E tests
Unit tests are only one side of the testing story. We still need confidence that user flows work in a browser. Cypress is a practical tool for this because it gives you a real browser, useful debugging output, and straightforward configuration.
Install Cypress as a development dependency:
npm install -D cypress
Open Cypress once to initialize its default folder structure:
npx cypress open
Add scripts to package.json so developers and CI have explicit, repeatable commands:
{
"scripts": {
"test": "vitest",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:e2e": "cypress run",
"test:e2e:open": "cypress open"
}
}
At this point, run your tests at least once to verify the setup is healthy:
npm run test:unit
npm run test:e2e
Add Formatting Support
ESLint catches quality issues, but it is not a complete formatting solution. A baseline project should also have deterministic formatting so code reviews can focus on behavior instead of spacing and punctuation.
Add Prettier
Install Prettier and the ESLint bridge package:
npm install -D prettier eslint-config-prettier
Create a baseline Prettier configuration:
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}
Add a simple ignore file so generated artifacts are skipped:
.next
coverage
node_modules
dist
Now wire the scripts into package.json:
{
"scripts": {
"lint": "next lint",
"format": "prettier --write .",
"format:check": "prettier --check ."
}
}
Run formatting once so your repository starts in a clean state:
npm run format
Add Pre-commit Quality Gates
Automation is helpful. Early automation is even better. You can prevent accidental low-quality commits by running linting and tests before every commit.
Add Husky and lint-staged
Install the required packages:
npm install -D husky lint-staged
Initialize Husky:
npx husky init
Configure lint-staged in package.json:
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,md,css,scss}": ["prettier --write"]
}
}
Then update the generated pre-commit hook to run lint-staged:
echo "npx lint-staged" > .husky/pre-commit
Finally, restore execute permissions on the hook file:
chmod +x .husky/pre-commit
Add Environment Variable Structure
Every real application eventually needs environment variables. Do not wait until production to organize this. Start with explicit examples so onboarding stays straightforward.
Create a local environment file:
touch .env.local
Create a committed example file:
cat <<'EOF' > .env.example
NEXT_PUBLIC_APP_NAME="New App"
NEXT_PUBLIC_API_BASE_URL="http://localhost:3000/api"
EOF
Then ensure local environment files are ignored:
grep -q "^.env.local$" .gitignore || echo ".env.local" >> .gitignore
Add Path Aliases
Long relative imports become painful very quickly. A basic alias setup keeps import statements readable and easier to refactor.
If your tsconfig.json does not already include aliases, add them:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
Now imports can shift from this:
import { Button } from '../../../components/Button'
To this:
import { Button } from '@/components/Button'
Add Continuous Integration
Local checks are good, but they are not enough. You still need a neutral environment to verify linting, tests, and builds for every pull request.
Create the GitHub Actions workflow folder:
mkdir -p .github/workflows
Create a baseline CI workflow:
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run format:check
- run: npm run test:unit
- run: npm run build
Add Dependency Hygiene
Dependencies age quickly. If you never audit and update them, your baseline gets stale and vulnerable. This does not need to be complicated; it needs to be habitual.
Audit dependencies:
npm audit
Check outdated packages:
npm outdated
Apply safe updates:
npm update
Final Verification Checklist
Before you call the baseline complete, run the full suite of developer checks:
npm run lint
npm run format:check
npm run test:unit
npm run build
If all four commands pass, you are no longer at the skeleton stage. You now have a practical baseline that supports collaboration, quality checks, and predictable releases.
That is the point of this setup process: not perfection, but momentum with guardrails.