9 November 2026 · 12 min read · BlitzHash · Cloud, DevOps & Security
GitHub Actions deploy for Next.js: a checklist for AI-built apps
Short answer: to set up a GitHub Actions deploy for a Next.js app that AI helped you build, use two small workflows. The first runs on every pull request and does npm ci, lint, type-check, a dependency audit and npm run build. The second runs only on main. It deploys with the Vercel CLI (vercel pull, vercel build, vercel deploy --prebuilt --prod), keeps tokens in GitHub Secrets, and ends with a smoke test that fails loudly if production is broken. Then run a workflow linter over the YAML before you trust it. If you would rather start from a pipeline someone has already reviewed, our Solid Start for AI-built apps sets this up with you.
This guide is for founders who clicked "export to GitHub" in Lovable, Bolt, Replit or v0, or who built in Cursor, and now have a repo, a Vercel project and a vague feeling that deploys "just happen". That feeling is fine until a deploy breaks checkout on a Friday evening, or until a security release lands and you have no safe, quick way to ship the patch.
Why a GitHub Actions deploy matters for an AI-built Next.js app
Vercel's Git integration already deploys every push. So why add GitHub Actions at all? Three reasons come up again and again in AI-built codebases.
1. Nothing checks the build before it reaches customers. Code generators are good at producing code that runs in the preview and quietly breaks something else: a type error in a page nobody opened, or a removed environment variable. A pull-request check that runs npm run build catches most of these before they merge.
2. Security patches need a fast, boring path to production. On November 3, 2026 the Next.js team shipped a security release (v16.4.5 and v15.5.29) covering cache disclosure and cookie exposure issues. Founders with a working pipeline bumped one line in package.json, watched the checks pass and merged. Founders without one either hand-deployed from a laptop or waited.
3. You control the order of operations. With a workflow you decide what has to pass before production changes: tests, audit, an approval click, and a smoke test afterwards. The AI tool that wrote the code no longer decides that for you.
If you have not yet confirmed that you own the repo, the Vercel project and the billing (not the freelancer or the builder platform), do that first. Our guide to owning the repo, environments and billing walks through it.
What we tested (November 9, 2026)
We did not want to publish another copy-paste YAML file, so we ran a small experiment on a clean machine:
1. We wrote the kind of deploy workflow we keep finding in AI-generated repos (below, "the risky version"). 2. We ran two free workflow linters over it: actionlint 1.7.12 and zizmor 1.32.0, a security-focused GitHub Actions auditor. 3. We rewrote it as two hardened workflows and linted again. 4. We ran the smoke-test script from this post against blitzhash.com, once correctly and once against a broken base URL, to prove that it actually fails when it should.
The risky version (do not copy)
name: deploy
on: [push, pull_request_target]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: echo "Deploying ${{ github.event.pull_request.title }}"
- run: npm install && npm run build
- run: npx vercel --prod --token ${{ secrets.VERCEL_TOKEN }}
env:
OPENAI_API_KEY: sk-proj-REPLACE_ME_hardcodedIt looks harmless, and it would probably deploy. Here is what the tools said.
actionlint reported 2 problems: actions/checkout@v2 is too old to run on current GitHub runners, and github.event.pull_request.title is "potentially untrusted" inside an inline script. Someone can open a pull request whose *title* is a shell command.
zizmor reported 5 problems (3 high, 2 medium):
dangerous-triggers:pull_request_targetruns with your repository's secrets, and this workflow then checks out the stranger's pull-request code. That combination lets an outside contributor run their code with your Vercel token.template-injection: the same pull-request-title problem actionlint found.unpinned-uses: the action is referenced by a movable tag, not a fixed commit.excessive-permissions: nopermissions:block, so the job gets the default token scope.artipacked:actions/checkoutkeeps the Git credential on disk unless you setpersist-credentials: false.
What neither tool flagged: the hard-coded sk-proj-... key sitting in the YAML, and npm install instead of npm ci. Linters check workflow *structure*. They do not replace secret scanning or a human reading the file. That was the most useful lesson of the test.
Step 1: The pull-request check workflow
Create .github/workflows/ci.yml. It runs on every pull request and on pushes to main, with read-only permissions:
name: CI
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint --if-present
- run: npx tsc --noEmit
- run: npm audit --omit=dev --audit-level=high
- run: npm run buildWhat each line buys you, in plain English:
- `npm ci` installs exactly what
package-lock.jsonsays. `npm ci` fails if the lock file andpackage.jsondisagree, which is the point: you find out in CI, not in production. - `npx tsc --noEmit` type-checks every file, including pages the AI edited that nobody clicked.
- `npm audit --audit-level=high` fails the check when a production dependency has a known high or critical advisory. Expect some noise; treat a failure as "read this", not "panic".
- Pinned commit hashes (the long strings after
@) mean the action cannot change underneath you. The comment keeps the human-readable version. - `concurrency` cancels an older run when you push again, so you stop paying for and waiting on stale builds.
The commit hashes above are the ones we pinned on November 9, 2026. Check the actions' release pages when you copy them.
Step 2: The production deploy workflow
Create .github/workflows/deploy-production.yml. This follows the pattern in Vercel's GitHub Actions guide: pull the project settings, build in the runner, then upload the prebuilt output.
name: Deploy production
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-production
cancel-in-progress: false
env:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 20
environment:
name: production
url: ${{ steps.deploy.outputs.url }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm install --global vercel@65.12.1
- run: vercel pull --yes --environment=production --token="$VERCEL_TOKEN"
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
- run: vercel build --prod --token="$VERCEL_TOKEN"
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
- id: deploy
run: |
url=$(vercel deploy --prebuilt --prod --token="$VERCEL_TOKEN")
echo "url=$url" >> "$GITHUB_OUTPUT"
env:
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
- name: Smoke test production
run: bash scripts/smoke-test.sh "$PRODUCTION_URL"
env:
PRODUCTION_URL: ${{ vars.PRODUCTION_URL }}Four details matter more than they look:
1. `cancel-in-progress: false` on the deploy. You never want a half-finished production deploy cancelled by the next push. Deploys queue instead. 2. `environment: production`. In GitHub, go to Settings → Environments → production and add yourself as a required reviewer. Every production deploy then waits for one click. GitHub's environments docs explain the protection rules. Environment secrets are also only released to jobs that reference that environment. 3. The token only lives in `env:` on the steps that need it. It is never pasted into the command text, so a later echo of the command cannot print it. 4. Pinned Vercel CLI version (65.12.1 was current on November 9, 2026). vercel@latest means a CLI change can break your deploy on a random Tuesday. Bump it on purpose.
Turn off double deploys
If Vercel's Git integration is still connected, every push to main now deploys twice: once by Vercel, once by your workflow. Either disconnect Git in the Vercel project, or set "git": { "deploymentEnabled": false } in vercel.json (see Vercel's Git configuration docs). Many teams keep Vercel's automatic *preview* deploys and let Actions own production. Decide which you want and write it down.
Where the three Vercel values come from
VERCEL_TOKEN: create it in your Vercel account settings under Tokens. Give it a clear name likegithub-actions-prod, set an expiry, and scope it to the team that owns the project.VERCEL_ORG_IDandVERCEL_PROJECT_ID: runvercel linkonce on your machine; they appear in.vercel/project.json. Do not commit that folder.
Add all three under GitHub → Settings → Secrets and variables → Actions, ideally as environment secrets on production. Add PRODUCTION_URL (for example https://yourapp.com) as an environment *variable*, not a secret, since it is not sensitive.
Step 3: A smoke test that actually fails
Most AI-generated pipelines end at "deploy succeeded". That only means files were uploaded. Save this as scripts/smoke-test.sh:
#!/usr/bin/env bash
# Post-deploy smoke test: fails the workflow if production looks broken.
set -euo pipefail
BASE="${1:?usage: smoke-test.sh https://your-domain.com}"
fail=0
check() { # path expected_status [must_contain]
local path="$1" want="$2" text="${3:-}" body code
body=$(mktemp)
code=$(curl -sS -o "$body" -w '%{http_code}' --max-time 20 --retry 3 --retry-delay 5 "$BASE$path" || echo 000)
if [ "$code" != "$want" ]; then echo "FAIL $path -> $code (wanted $want)"; fail=1
elif [ -n "$text" ] && ! grep -qi -- "$text" "$body"; then echo "FAIL $path -> $code but missing \"$text\""; fail=1
else echo "ok $path -> $code"; fi
if grep -Eq 'sk_live_[0-9A-Za-z]{10,}|sk-proj-[0-9A-Za-z_-]{20,}|service_role' "$body"; then
echo "FAIL $path leaks something that looks like a secret"; fail=1; fi
rm -f "$body"
}
check / 200 "og:title"
check /contact 200
check /this-page-should-not-exist-smoke 404
exit $failSwap in your own important paths: /login, /pricing, a public API health route. The home-page check also looks for the text og:title, which almost every Next.js page includes in its metadata; replace it with a phrase that only appears when your page rendered properly, such as your brand name. The last check matters more than it looks. A site that returns 200 for a page that does not exist usually has a catch-all route that will confuse search engines and hide real errors.
Our run against blitzhash.com: all three checks passed (/ 200, /contact 200, the fake page 404) in about 1.1 seconds. Pointed at a wrong base URL, the same script printed FAIL / -> 308 and FAIL /contact -> 404 and exited with code 1, which is exactly what should stop a pipeline and alert you.
The script also greps each page for strings that look like a Stripe live key, an OpenAI project key or the word service_role. That is a tripwire, not a scanner. Our post on secrets leaking into the browser covers the full check.
Optional: a launch-readiness gate
For a broader post-deploy check, our open-source blitzhash-launch-ready-check scans a live URL for security headers, exposed-key patterns, rate-limit signals, SEO basics and cookie flags, and it has a --fail-under flag built for CI:
npx --yes github:blitzhash360-web/blitzhash-launch-ready-check --url "$PRODUCTION_URL" --fail-under 70On blitzhash.com this morning it scored 89/100 (grade B). The one finding was "no rate-limit signals detected" on the home page, which is a heuristic worth knowing about rather than an emergency. Pick a threshold your current site passes, then raise it as you fix things.
Step 4: Lint the workflows before you trust them
Run both linters locally or as another CI job:
# actionlint: syntax, expressions, outdated actions
curl -sL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash | bash
./actionlint
# zizmor: security audit of workflows (Python)
pipx install zizmor
zizmor --offline .github/workflows/After the rewrite, our two hardened workflows passed actionlint with zero findings. zizmor dropped from 3 high and 2 medium findings to one low "ad-hoc installation of packages" note about npm install --global vercel. If you want that gone too, add vercel as a pinned devDependency and call it with npx vercel.
The GitHub Actions deploy checklist
Print this or paste it into your repo's README. Every item is something we check in a real review.
Repo and access
☐ The GitHub repo, Vercel project and billing are owned by your company account, not a contractor ☐ Branch protection on main: pull request required, CI check required to pass ☐ No secrets committed anywhere in Git history (.env, YAML, .vercel/)
Pull-request checks (ci.yml)
☐ Uses npm ci, not npm install ☐ Runs lint, type-check (tsc --noEmit) and npm run build ☐ Runs npm audit --omit=dev --audit-level=high (or Dependabot alerts are switched on and someone reads them) ☐ permissions: contents: read at the top ☐ Actions pinned to a commit hash, with persist-credentials: false on checkout
Production deploy (deploy-production.yml)
☐ Triggers only on main (plus manual workflow_dispatch), never on pull_request_target ☐ Uses a GitHub environment named production with you as a required reviewer ☐ Vercel token is an environment secret, scoped, named and set to expire ☐ Vercel CLI version is pinned ☐ Deploy concurrency queues instead of cancelling ☐ Vercel Git auto-deploy for production is off, so there is no double deploy
After deploy
☐ Smoke test hits your three to five most important URLs and fails the job on a wrong status ☐ Smoke test confirms a fake URL returns 404 ☐ Someone gets notified when the deploy workflow fails (GitHub notifications or a chat integration) ☐ You know how to roll back: Vercel's Instant Rollback to the previous production deployment, tried once before you need it
Security hygiene
☐ actionlint and zizmor pass, or every remaining finding is understood ☐ No untrusted ${{ github.event.* }} values inside run: scripts ☐ Next.js is on a patched version (16.4.5 / 15.5.29 or later as of the November 3, 2026 release)
If you want to see where the rest of your app stands, not only the pipeline, the free self-check takes a few minutes and covers security, privacy and launch basics.
Common mistakes we see in AI-generated pipelines
- One giant workflow that tests and deploys on every event. Split checks and deploys; give the deploy the fewest triggers and the tightest permissions.
- Secrets in `.env.production` committed "just for CI". Vercel already stores your runtime environment variables;
vercel pullfetches them into the runner. They do not need to live in Git. - `npm install` plus a deleted lock file. The AI "fixed" a dependency error by removing
package-lock.json. Every build now resolves slightly different versions. Restore the lock file and usenpm ci. - A pipeline nobody has seen fail. Break the smoke test on purpose once (point it at a bad URL, as we did) and confirm you get notified. A guard you have never seen trigger is a guess.
- No staging step for risky changes. For payment or auth changes, merge to a
stagingbranch with its own preview URL first. Our Replit-to-production guide has a broader list of pre-launch checks for auth, rate limits and backups.
FAQ
Do I need GitHub Actions if Vercel already deploys on every push?
Not strictly. Vercel's Git integration is a perfectly good way to deploy. What you lose without Actions is the gate: nothing has to pass before production changes, and nothing checks production after. The smallest useful step is just ci.yml plus branch protection, while Vercel keeps deploying. Add deploy-production.yml when you want approvals and smoke tests.
Is it safe to put my Vercel token in GitHub?
Yes, if it is stored as a GitHub Actions secret (ideally an environment secret on production), scoped to one team, given an expiry, and only passed through env: to the steps that use it. It is not safe in the YAML, in a committed .env file, or in any workflow triggered by pull_request_target.
Will this work for apps exported from Lovable, Bolt or v0?
Yes, as long as the export is a standard Next.js project with a package.json, a lock file and a build script. If the export uses Vite instead of Next.js, the CI file stays almost identical. The Vercel CLI steps work the same way because vercel build detects the framework.
How long does this take to set up?
For a typical AI-built Next.js repo, plan on an afternoon: about an hour for the two workflows and secrets, an hour fixing whatever tsc and npm audit surface the first time, and a final pass to test a failing smoke test and a rollback. The first run almost always finds something. That is the point.
Get a pipeline you can trust
You do not need a DevOps team to ship safely, but you do need a deploy path that checks your work and tells you when something breaks. If you want a senior engineer to set up or review this with you, see Solid Start, or book a free technical assessment. It is a 15-minute launch review call where we look at your AI-built app and repo and tell you what is risky before your next deploy.
Sources
- Vercel: How can I use GitHub Actions with Vercel?
- Vercel: Git configuration
- GitHub Docs: Secure use reference
- GitHub Docs: Managing environments for deployment
- npm Docs: npm ci
- actionlint
- zizmor
- Next.js: November 2026 Security Release
