Getting Started with NextJS on Vercel

Image of Author
February 24, 2022 (last updated September 16, 2024)

Introduction

This is an opinionated link-first getting started guide. It is my preferred setup as of last authoring, and as such 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.

Some aliases I will use on the command line are as follows: g for git, g ap for git add -p, g cm for git commit -m, and nr for npm run.

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=.
echo "node_modules" >> .gitignore
g ap
g cm "First commit."
g push -u origin main

Next.js

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

"scripts": {
  "dev": "next dev",
  "build": "next build",
  "start": "next start",
  "lint": "next lint"
}
echo ".next" >> .gitignore

Typescript

// pages/index.tsx

export default function Index() {
  return (
    <main>
      <h1>App</h1>
    </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
  }
}

Other settings than these will be generated by Next.js, these are just the two I want to mention. 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

Recall that the following is already in your package.json "scripts":

// 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.)

/* lib/styles.css */

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

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

import "../lib/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
// .prettierrc

{
  "singleQuote": true,
  "semi": false
}

Deploy to Vercel

There is a Vercel CLI, but since the developer experience of using the Vercel Dashboard GUI is nice. I prefer manually clicking through the deploy steps in the GUI.

Confirm everything is working locally with nr dev. Then, commit your latest code and use the Vercel dashboard to deploy your app.

Custom Domain

Once you've procured a domain and also deployed to Vercel, you can go to your Vercel project and navigate to Settings > Domains to setup your domain. As of last authoring, I setup the following custom records in my domain settings.

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

[example.com] | A | 1-minute | 76.76.21.21
[www.example.com] | CNAME | 1-minute | cname.vercel-dns.com

Vercel takes care of SSL and apex domain to subdomain redirects behind the scenes.

Playwright

npm init playwright@latest
{
  "scripts": {
    "test": "playwright test"
  }
}

I left most of the default settings intact. I enabled the two mobile-browser projects. Also, here are the settings I changed.

const config: PlaywrightTestConfig = {
  reporter: process.env.CI ? "dot" : "list",
  use: {
    baseURL: "http://localhost:3000",
  },
  webServer: {
    command: "npm run build && npm run start",
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
};

Note that a local server is usually up via nr dev, which might deviate from nr build && nr start, but I intentionally do not use command: process.env.CI ? npm run build && npm run start : npm run dev because, if the dev server is not running, I'd rather test against the most production-like build. That said, if the tests pass on local against nr dev I'm confident enough to push to remote.

// tests/home.spec.ts

import { test, expect } from "@playwright/test";

test("home page has header", async ({ page }) => {
  await page.goto("/");
  await expect(page.getByRole("heading")).toContainText("App");
});

CICD with Github Actions

npm init playwright@latest sets up a good, minimal cicd yaml file at .github/workflows/playwright.yml. The only change I make is getting rid of a reference to a master branch to avoid confusion (I only use main and dev).

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 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 I work on and will submit PRs to the main branch. This will trigger the CICD pipeline and failing tests will prevent merging to main.

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", you might consider heavy hook usage to prevent erroneous deployments to main/prod.

PWA

// public/manifest.json

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

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

Other resources for PWA:

Next Steps

Conclusion

Thanks for reading :D