Gatlin.io Icon
Image of Austin Gatlin's face
February 24, 2022 (last updated June 26, 2022)

Getting Started with Next.js on Vercel

Introduction

This is a link-first getting started guide which I define and defend in the linked blog post. In brief, the goal is to create a sequential list of steps, each with many links to resources. The code snippets in each section are elaborations on the links regarding what worked at the time of last usage, but are to be considered less trustworthy than the links themselves, and whatever they tell you to do.

This guide is opinionated in that it is essentially my favorite setup as of last authoring, and is also liable to change as I change tooling opinions over time, etc.

This guide will walk through setting up a Next.js app deployed on Vercel on a custom domain. The codebase will include/use Typescript, ESLint, Prettier, TailwindCSS, Jest, React Testing Library, Playwright, Github Actions, Github CLI, PWA support, and asdf.

Prepare

asdf install nodejs [version]
asdf global nodejs [version]
brew update && brew upgrade gh
mkdir app
cd app
asdf local nodejs [version]

Setup Github Repository

g init
gh repo create app --public --source=.
g push -u origin main

g = git and (further down) nr = npm run

Create Next.js App

npm i next react react-dom
// package.json

"scripts": {
  "dev": "next dev",
  "build": "next build",
  "start": "next start"
}

Typescript

// pages/index.tsx

export default function Index() {
  return (
    <main>
      <h1>App</h1>
      <HelloWorldComponent />
    </main>
  )
}

Run nr dev. Next.js will detect the .tsx file and walk you through their preferred Typescript setup.

// tsconfig.json

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": false
  }
}

As of this writing my personal preference when coding TypeScript is to only use types when I want the type safety. My current approach to achieve this is keeping the compiler strict while disabling noImplicitAny, and writing every file (even ones without types) as .ts or .tsx.

ESLint

// package.json
{
  "scripts": {
    "lint": "next lint"
  }
}

Run nr lint to trigger the Next.js eslint setup.

TailwindCSS

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

(As far as I can tell, following the TailwindCSS Next.js setup guide results in a css bundle with less PostCSS plugins than the default. It seems you have to manually reintroduce those plugins yourself, if you'd like, which I won't cover in this elaboration, but the links should lead you down the right path.)

/* src/styles.css */

@tailwind base;
@tailwind components;
@tailwind utilities;
// tailwind.config.js

module.exports = {
  content: ['./pages/**/*.{js,ts,jsx,tsx}', './src/**/*.{js,ts,jsx,tsx}'],
  theme: {
    extend: {},
  },
  plugins: [],
}
// pages/_app.tsx

import '../src/styles.css'

function App({ Component, pageProps }) {
  return <Component {...pageProps} />
}

export default App
// pages/index.tsx

<main className="flex flex-col items-center">
  <h1 className="text-6xl">App</h1>
</main>

Prettier

npm i -D prettier prettier-plugin-tailwindcss
// package.json

{
  "prettier": {
    "singleQuote": true,
    "semi": false,
    "arrowParens": "avoid"
  }
}

Deploy to Vercel

There is a Vercel CLI, but since the DevX for the Vercel Dashboard is nice, I prefer manually clicking through the deploy steps.

Attach Custom Domain

Vercel project settings show you how to add a CNAME in your DNS resources. It will look something like:

[custom domain name] | CNAME | 1-hour | cname.vercel-dns.com

Playwright

npm i -D @playwright/test
npx playwright install
{
  "scripts": {
    "test": "playwright test"
  }
}
// playwright.config.ts

import { PlaywrightTestConfig } from '@playwright/test'

const config: PlaywrightTestConfig = {
  forbidOnly: !!process.env.CI, // fail if test.only makes it to CI
  retries: process.env.CI ? 2 : 0, // retries only on CI
  workers: process.env.CI ? 1 : undefined, // opt out of parallel tests on CI
  reporter: 'dot',
  use: {
    baseURL: 'http://localhost:3000',
  },
  webServer: {
    command: 'npm run build && npm run start',
    port: 3000,
    reuseExistingServer: !process.env.CI, // allow local dev server when running locally
  },
}

export default config

The above are some of the most important settings, in my opinion. webServer is how you will run the server on CI and baseURL is how you will use relative paths in your tests.

import { test, expect } from '@playwright/test'

test('greets user', async ({ page }) => {
  await page.goto('/')
  const main = page.locator('main')
  await expect(main).toHaveText(/App/)
})

CICD with Github Actions

# .github/workflows/ci.yml

name: CI
on:
  push:
    branches: [main, dev]
  pull_request:
    branches: [main]
jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
        with:
          node-version: '16.x'
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
      - uses: actions/upload-artifact@v2
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

Embrace a PR Workflow

A note about workflow: Vercel branding is "Deploy. Preview. Ship.", where "Preview" is for checks, quality assurance, etc., so a trunk-based workflow where tests (aka, checks?) run before deploy is not the Vercel way. Instead, a branch-based paradigm is preferred, where tests are ran on PRs while simultaneously being deployed to a preview URL. Vercel deploy hooks will let you work around this, but it gets non-trivial. Just keep in mind: If you push directly to main it will immediately try to deploy, no matter your pipeline.

My current strategy is to lean in. I want as little maintenance as possible (no code is the best code). So, I have a dev branch and will PR to main as a way of "forcing" failed tests to prevent getting red code on prod.

Skooh

npm i -D skooh
// package.json

{
  "scripts": {
    "prepare": "skooh"
  },
  "hooks": {
    "pre-commit": "npm run lint && npm run format
  }
}

(Because it feels a bit too self-aggrandizing to recommend my own tool, I will mention here that husky is a great alternative git hooks manager.)

Since Vercel is "deploy first, preview second", so you might consider heavy hook usage to prevent erroneous deployments to main/prod.

PWA

The Next.js PWA example puts the manifest link in _app.tsx instead of _document.tsx. I'll admit to occasional confusion regarding what content goes in which file. In my opinion, a manifest link is best in _document.tsx so that's where I put it. But, probably trust whatever the PWA example says, I guess.

// public/manifest.json

{
  "name": "App",
  "short_name": "App",
  "icons": [...],
  ...
}
// pages/_document.tsx

export default function Document() {
  return (
    <Html>
      <Head>
        <link rel="manifest" href="/manifest.json" />
      </Head>
    </Html>
  )
}

Other resources for PWA:

Add Domain Logic

For example, if I wanted to build an in-memory todo app, I would probably rip some of the code from the end of my blog post Reinventing Redux through React Refactors.

Conclusion and Next Steps

I have used Storybook for component maintenance on larger teams, but it is likely overkill for a personal project.

From here there are many tech-stack branches worth pursuing. Personally, I'm interested in exploring building further with Firebase, Supabase, and PWA + IndexedDB, to name a few. Also, see my previous blog post covering a Next.js, Vercel, FaunaDB, and Auth0 setup with Social Sign On if you are interested in that setup.

Thanks for reading :D