Skip to main content
ASoc
Tutorial

A Next.js CI Pipeline in 23 Lines, Timed Step by Step

Real per-step timing from a production run — 100 seconds total, cheapest checks first, no separate typecheck step because next build already does one.

The ASoc Team7 min read

A Next.js CI pipeline needs four checks — lint, format, test, build — run in the order that fails fastest and cheapest first, on one job, with no separate type-check step because next build already does that. This repository's whole pipeline is 23 lines and finishes in under two minutes. Here is the actual config, the real per-step timing from a production run, and why it stays this small.

The pipeline, in full

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:
permissions:
  contents: read
jobs:
  verify:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npm run format:check
      - run: npm run test --if-present
      - run: npm run build

One job. No matrix, no parallel jobs, no separate deploy step — Vercel's own GitHub integration handles deployment from the same push, so this workflow's only job is to answer yes-or-no before that happens.

Real timing from a production run

GitHub's API reports per-step timestamps for every run. Here is the breakdown from a recent run on main, step by step:

StepDuration
actions/checkout@v43s
actions/setup-node@v4 (with npm cache)5s
npm ci19s
npm run lint12s
npm run format:check6s
npm run test --if-present7s
npm run build43s
Total job time~100s

The last ten runs on main cluster tightly around 96–106 seconds each — npm run build alone is nearly half the job, which is the number that decides the ordering below.

Why the order is lint → format → test → build, not build first

The four checks aren't listed alphabetically or by importance — they're listed cheapest-to-detect first. npm run build takes 43 seconds and type-checks the entire project as part of compiling it; if a push has a lint error, failing at 12 seconds into the job (npm run lint, after the 19-second npm ci) is strictly better than waiting for a 43-second build to fail on the same file. GitHub Actions steps run sequentially and stop at the first failure by default, so step order is a real lever, not cosmetic — the same reasoning as putting the cheapest test first in a Vitest suite, applied to job steps instead of test cases.

No separate typecheck step, on purpose

Most Next.js CI examples add a dedicated tsc --noEmit step before the build. This pipeline doesn't have one, because it would be redundant work: next build type-checks every file it compiles as part of producing the production bundle — the same guarantee a standalone tsc --noEmit run gives, paid for once instead of twice. Skipping the extra step isn't a corner cut; it's recognizing that npm run build's 43 seconds already includes a full project type-check, confirmed by the "Running TypeScript" line in next build's own output. A pipeline that runs both is paying to type-check the codebase twice on every push.

npm run test --if-present, a flag from before there were tests

One line is a small tell about this pipeline's history: npm run test --if-present — the --if-present flag makes npm silently skip the script if it isn't defined in package.json, instead of failing the job. package.json has defined a test script (vitest run) for a long time now, so the flag is currently a no-op; it's a defensive leftover from when the test suite didn't exist yet and the workflow needed to not fail on a project with zero tests. It's harmless — npm test --if-present behaves identically to npm test once the script exists — but it's the kind of line worth removing the next time this file changes, since it now documents a state the repository outgrew.

Reading a red run

A failure in any one step stops the job there — the remaining steps never run, so the job's total duration is also a rough signal of which step failed. Working from the timing table above: a run that stops around the 40-second mark (checkout + setup + npm ci + lint) failed on lint; one that stops around 50–55 seconds failed on format or the test suite; one that runs past a minute and then fails is almost always the build. That heuristic works because timeout-minutes: 15 gives the job roughly nine times its actual ~100-second runtime as headroom — generous on purpose, since a genuinely hung step (a dependency install stuck resolving, a build that enters an infinite prerender loop) is a different failure mode than a normal red run and deserves to actually time out rather than appear to hang forever in the Actions UI.

What the config doesn't have

Two omissions are worth naming, because both are common additions that this pipeline deliberately doesn't carry. There's no concurrency block, so pushing three commits to the same PR in quick succession queues three full runs rather than canceling the stale ones — harmless at ~100 seconds a run and one job at a time, but the kind of thing that starts wasting real minutes once either number grows. And there's no cache step beyond cache: npm on actions/setup-node@v4, which caches npm's download cache keyed on package-lock.json's hash — it speeds up npm ci's package-fetching, not the next build step, which has no persistent cache across runs here. A .next/cache restore step is the next lever if npm run build's 43 seconds ever becomes the bottleneck worth optimizing; at the current size, it isn't yet.

Troubleshooting

SymptomCauseFix
npm ci fails with a lockfile mismatchpackage.json and package-lock.json drifted (someone ran plain npm install locally and didn't commit the lockfile)Run npm install locally, commit the updated package-lock.json, and use npm ci locally too so the same drift can't happen again
CI passes but a type error ships anywayThe error is in a file next build doesn't compile on this route tree — rare, but possible for orphaned files with no import path from any pagetsc --noEmit project-wide would catch this the build misses; weigh that against doubling type-check time on every push
The job passes locally but fails in CI on the same commitA Node version mismatch — actions/setup-node@v4 pins node-version: 22, a local machine may run something elseMatch the CI Node version locally, or better, let a version-manager config file (.nvmrc) be the single source both read from
format:check fails but the diff looks unchangedprettier-plugin-tailwindcss re-sorts class strings on save; an editor without the plugin active can produce styled-but-unsorted class listsRun npm run format locally before pushing, not just an editor's built-in formatter
A push to a fork's branch doesn't trigger CIThe workflow's on.push.branches is scoped to [main]; only pull_request triggers on other branchesOpen the PR — on: pull_request (with no branch filter) is what actually gates a feature branch here

Frequently asked questions

Should a Next.js CI pipeline run tests before or after the build? Before, and cheaper checks before that. This pipeline runs lint (12s), format (6s) and tests (7s) — 25 seconds combined — before the 43-second build, so a broken commit fails in under half a minute instead of after the most expensive step finishes.

Do I need a separate TypeScript check step in CI? Only if some part of the codebase isn't reachable from next build's own compilation — genuinely rare in an App Router project, since every page and every module it imports gets type-checked as part of the build. Where it's reachable, a dedicated tsc --noEmit step is pure duplicate work.

How long should a Next.js CI job take? There's no universal number — it scales with project size — but the shape to aim for is what this one has: the build dominates the total (43 of ~100 seconds here), and every cheaper check runs first so a common mistake (a lint error, an unformatted file) fails fast instead of waiting behind it.

Is one job better than splitting lint/test/build into parallel jobs? For a project this size, yes — parallel jobs add GitHub Actions queue and setup overhead (each job re-runs actions/checkout and actions/setup-node) that a ~100-second sequential job doesn't pay at all. Parallelizing earns its cost back once any single step is slow enough that wall-clock time matters more than total compute time — a build in the minutes, not tens of seconds.

Templates in this post

ASoc Lura, a multi-industry admin dashboard, and ASoc Scholar Admin, built for large-scale institutional dashboards, both ship from repos with the same lint-format-test-build shape as this one — the pattern holds regardless of how many screens a dashboard product ends up with. ASoc Apex Admin pairs a dashboard with a full UI kit, which is the case where a slow, unordered pipeline costs the most: more components means more surface area for a lint failure that a fast-fail order would have caught in seconds instead of after a full build.

Browse the full sets: React admin templates, Next.js admin templates, and Tailwind admin templates.

Keep reading

Tutorial11 min read

A Next.js Contact Form with Server Actions, Zod and Resend

No API route, no client fetch, and it still submits with JavaScript off. Validation, a honeypot, a rate limit that survives serverless, and the from-address trap that kills deliverability.

Read more
Tutorial10 min read

A Next.js Content Security Policy That Keeps Static Rendering

The documented nonce recipe turns every route it touches dynamic. The static-safe policy we ship instead, what 'unsafe-inline' really costs, and what the header still blocks.

Read more