Vercel Deployment
Shipkit deploys to Vercel with zero configuration. For first-time deployment steps including one-click deploy, see the deploy guide.
This page covers production configuration and the failure patterns specific to Shipkit and its downstream forks. If you maintain a site forked from Shipkit (or Bones), read Upstream Sync & Route Conflicts — route collisions after an upstream sync are the most common cause of broken Vercel builds in downstream repos.
Project Setup
Vercel reads most settings from vercel.json at the repo root, so a fresh import needs almost nothing configured manually:
| Setting | Value | Source |
|---|---|---|
| Framework preset | Next.js | Auto-detected |
| Build command | bun run build | vercel.json |
| Install command | bun install --frozen-lockfile | vercel.json |
| Dev command | bun run dev | vercel.json |
| Output directory | .next (default) | Framework preset |
Don't override the build or install commands in the Vercel dashboard — dashboard settings silently win over vercel.json, which makes downstream repos drift from upstream behavior.
vercel.json also configures serverless function limits for API routes (memory: 512, plus excludeFiles to keep bundles under Vercel's size cap) and per-path headers. Note the functions block only matches src/app/(app)/api/**/* — API routes added outside that path (e.g. src/app/api/ or another route group) won't inherit these limits, so either keep API routes under (app)/api/ or add a matching functions entry. If you add a heavy dependency to an API route and hit function size errors, extend excludeFiles there or outputFileTracingExcludes in next.config.ts.
One build command
bun run build # bun run generate && NODE_OPTIONS='--max-old-space-size=4096' next build --turbopack
bun run generate # providers index, content manifests, docs images, fumadocs-mdx types
Vercel runs the same build your machine runs. There used to be a separate build:vercel, and the two drifted: the Vercel one skipped sync-docs-images and fumadocs-mdx, so the fumadocs generator only ever ran at install time on Vercel. A fumadocs version bump then killed three cold builds one second into "Creating an optimized production build" while the same tree built fine locally. One script cannot drift that way.
generate is also the postinstall step, so a fresh clone can run dev or typecheck without a build first. The heap cap lives in the build script rather than a Vercel environment variable, which keeps it scoped to the build and visible in the repo — NODE_OPTIONS set as a project env var would also apply to every serverless function at runtime. Shipkit's dependency graph (Payload CMS, Builder.io, MDX pipeline) can exceed the default heap, which surfaces as an OOM kill or a silent exit code 137.
Keep the cap well under the container's 8GB. Turbopack's native allocations are not counted against the V8 heap, so a generous cap starves them: at 6144 the production build was SIGKILLed by the container OOM killer with no JavaScript heap out of memory line, because V8 had not yet collected.
The cap was never the fix, and the OOM is now closed by something else. 4096 shipped on
2026-10-20 (d092b787) and a build containing it (873f204d) was OOM-SIGKILLed an hour later.
6144, uncapped and 4096 have all produced failures. Partitioning every shipkit build since
2026-10-10 by whether it contains the cap commit shows no effect at all: 24.6% OOM without it
(29 of 118), 22.8% with it (26 of 114).
What closed it was LAC-4112 (78f5023a,
2026-10-25), which stopped writing the Turbopack filesystem cache on Vercel. Partition the same
builds by whether they contain that commit:
build contains 78f5023a | builds | out_of_memory |
|---|---|---|
| no | 206 | 55 (26.7%) |
| yes | 26 | 0 (0%) |
At the old rate, zero failures in 26 builds happens by luck about 3 in 10,000 times. The same
holds on shipkit-www, which got the flag via LAC-5488: zero memory failures since.
That PR was filed to fix build-cache size, not memory, and the memory win was never recognized. The mechanism is straightforward in hindsight: writing a persistent Turbopack cache means holding and serializing the whole module graph during the build, on top of compiling it. Skipping the write on Vercel removes that second copy.
A local measurement agrees that the build was never inherently an 8GB build. A full production
build on a 16GB machine, cache write enabled, peaks at 3.5GB total RSS across the process
tree, and it is concentrated in a single next-build process. cpus: 1 is doing its job, since
the page-data workers are 16-78MB each.
Three consequences, and they are the point of this section:
- Do not change the cap casually. Each change costs a dozen production builds to evaluate, and anything less is a coin flip read as a result. That is how 6144, uncapped and 4096 each got declared fixed. 4096 is now comfortable against a measured 3.5GB peak; leave it alone.
out_of_memoryis no longer automatically infrastructure. On a repo that already hasturbopackFileSystemCacheForBuild: process.env.VERCEL !== "1", an OOM is new information and worth reading. Redeploy-do-not-debug was the right reflex before 2026-10-25 and is the wrong one now.- On a Turbopack repo that OOMs and lacks the flag, add the flag. That is the fix, not a cap
and not a bigger builder. It does not apply to the
--webpackmembers of the family (bones,lash-www,TOH-new), whose OOMs, where they exist, are a different mechanism.
So read the error code before you read the diff, and let it tell you which case you are in:
vercel inspect <deployment-url> # out_of_memory + no FS-cache flag -> add the flag
# out_of_memory + flag present -> real regression, read it
# anything else -> your diff
Validate any build change against the production target, which enables more features and prerenders more pages than a preview, without touching the live alias:
vercel deploy --prod --skip-domain --force
At this failure rate a single green run proves nothing. Expect to repeat it before you believe it.
A drift test (tests/unit/config/build-scripts.test.ts) asserts that vercel.json points at build, that build calls generate, and that no second build script exists.
Environment Variables
Set in Vercel Dashboard → Project → Settings → Environment Variables, with separate values for Production and Preview where appropriate.
Minimum for a working deploy:
DATABASE_URL= # PostgreSQL connection string
AUTH_SECRET= # openssl rand -base64 32 (or set APP_SECRET to derive all secrets)
Everything else is optional — features auto-enable when their env vars are present (see src/config/features-config.ts for detection logic):
AUTH_GITHUB_ID= # GitHub OAuth client ID
AUTH_GITHUB_SECRET= # GitHub OAuth client secret
LEMONSQUEEZY_API_KEY= # Lemon Squeezy payments
LEMONSQUEEZY_STORE_ID= # Lemon Squeezy store
STRIPE_SECRET_KEY= # Stripe payments
STRIPE_PUBLISHABLE_KEY= # Stripe publishable key
BUILDER_API_KEY= # Builder.io visual editing
RESEND_API_KEY= # Email
OPENAI_API_KEY= # OpenAI
ANTHROPIC_API_KEY= # Anthropic
NEXT_PUBLIC_POSTHOG_KEY= # Analytics
Notes that matter on Vercel specifically:
AUTH_URLis derived fromVERCEL_URLautomatically; only set it explicitly for a custom production domain.- Feature flags are resolved at build time. Adding or removing an env var requires a redeploy to take effect.
NEXT_PUBLIC_*vars are inlined into the client bundle at build time — never put secrets in them.
See Environment Variables for the full reference.
Route Group Conventions
Shipkit's src/app/ uses route groups to organize pages without affecting URLs:
src/app/
├── (app)/ # Main application — layouts, pages, api/
│ ├── (kit)/ # Marketing/landing pages (page.tsx here serves "/")
│ ├── (authentication)/ # Sign-in, sign-up flows
│ ├── (dashboard)/ # Protected routes
│ ├── (admin)/ # Admin panel
│ ├── (demo)/ # Demo/example pages
│ ├── (legal)/ # Terms, privacy
│ ├── [...slug]/ # CMS catch-all: renders Payload / Builder.io pages
│ └── @modal/ # Parallel route slot (intercepted auth modals)
└── (cms)/ # Payload admin UI (/cms) and REST API (/cms-api)
Route groups (name) are stripped from the URL, so two pages in different groups can resolve to the same path. Next.js fails the build with:
You cannot have two parallel pages that resolve to the same path.
Rules to avoid collisions:
/is owned by(app)/(kit)/page.tsx. If your downstream adds its own landing group (e.g.(main)/page.tsx), delete or move the upstream one — don't keep both.- Before adding a page in a new group, check every group for the same resolved path:
find src/app -name "page.tsx" | grep -v node_modules. - The
(app)catch-all[...slug]is lower priority than static routes, so it won't conflict at build time. A static page still shadows the CMS page at the same URL. - Keep downstream-specific pages in a clearly-named group (e.g.
(site)) rather than editing upstream groups; this makes upstream merges cleaner.
Parallel Route Slots and default.tsx
(app)/@modal/ is a parallel route slot used for intercepted auth modals. Every parallel slot must have a default.tsx (Shipkit's returns null) — it's what Next.js renders for the slot on routes that don't match it, including hard navigations.
Symptoms of a missing default.tsx:
- Build error or 404s on hard reload of any page under the layout that declares the slot
Error: No default component was found for a parallel route
If an upstream sync adds a new @slot/ directory to a layout your downstream customized, verify the slot's default.tsx survived the merge. When deleting a parallel slot, delete the slot reference from the corresponding layout.tsx props too.
Upstream Sync & Route Conflicts
Downstream repos pull upstream changes with:
bun run upstream:pull # tsx scripts/git-sync-upstream.ts — creates a sync PR
bun run upstream:pull -- -d # direct merge into main, no PR
The script picks the first accessible upstream (premium shipkit, falling back to public bones, or UPSTREAM_REPO_URL if set), merges upstream/main into a temp branch, and opens a PR.
Why syncs break Vercel builds: git merges files, not routes. If upstream moved or added a page.tsx at a path your downstream also defines (in a different route group), the merge completes cleanly — no git conflict — but the Next.js build fails with a duplicate-path error. This is the most common Vercel failure across Shipkit downstreams.
After every upstream sync, before merging the sync PR:
- Build locally:
bun run build. A route conflict fails fast with the two conflicting file paths in the error. - Resolve by deleting the duplicate you don't want — usually the upstream copy if your downstream intentionally overrode that page, or your copy if you want the new upstream version.
- Check parallel slots:
find src/app -type d -name "@*"— each needs adefault.tsx. - Check the lockfile: if the sync changed
package.json, runbun installand commit the updated lockfile, or the Vercel install step fails (see below).
The preview deployment on the sync PR is your safety net — never merge a sync PR whose preview build failed.
Common Build Errors
Frozen lockfile mismatch
error: lockfile had changes, but lockfile is frozen
The install command is bun install --frozen-lockfile, so package.json and the lockfile must agree. Happens when a dependency change (often from an upstream sync) lands without a regenerated lockfile. Fix: bun install, commit the lockfile, push.
Route conflict
You cannot have two parallel pages that resolve to the same path
See Route Group Conventions. Delete one of the two pages.
Missing default.tsx
See Parallel Route Slots.
Module not found after sync
Module not found: Can't resolve '@/components/...'
Upstream deleted or moved a file your downstream still imports (or vice versa). Fix the import or restore the file; bun run typecheck catches these before Vercel does.
Database unreachable during static generation
Error occurred prerendering page ... ENOTFOUND / connection refused
A Server Component that queries the database is being statically generated at build time, but the database isn't reachable from the build (paused database, IP allowlist). Fix: make the page dynamic with export const dynamic = "force-dynamic", or make the database reachable at build time.
Out of memory
Build exits with code 137 or a heap allocation error. The build script already caps the heap at 6GB on an 8GB builder. If it still OOMs, trim optimizePackageImports additions and check for accidental large imports in Server Components.
Serverless function size exceeded
Max serverless function size of 50 MB (compressed) was exceeded
Extend excludeFiles in vercel.json (functions block) or outputFileTracingExcludes in next.config.ts for the offending route.
Vulnerable framework version blocked
Vercel refuses to promote deployments running a Next.js version with a known CVE, even when the build succeeds. Fix: bump next to the patched version, regenerate the lockfile, push.
next.config.ts Settings That Affect Vercel
Shipkit's config includes several settings that exist specifically because of Vercel's build/runtime environment. When syncing downstream, don't remove these without understanding why they're there:
| Setting | Why it matters on Vercel |
|---|---|
serverExternalPackages | Excludes native modules (isolated-vm), optional DB drivers, and ESM-only packages from the server bundle — these fail to compile or require() on Vercel. |
transpilePackages | Force-bundles ESM-only packages (estree-walker etc.) that Next.js would otherwise externalize; externalizing them causes 500s at runtime (e.g. on /docs). |
experimental.serverActions.bodySizeLimit | Raises the Server Action payload cap for file uploads; the default rejects large uploads in production. |
experimental.optimizePackageImports | Tree-shakes icon/util libraries; reduces both build memory and function size. |
outputFileTracingExcludes | Keeps dev-only and heavy packages out of serverless function bundles — the main lever against the 50MB function limit. |
outputFileTracingIncludes | Ensures docs/** and src/content/** ship with functions that read them at runtime; without this, MDX pages 404/500 only in production. |
reactCompiler | Required by dependencies (e.g. @payloadcms/ui) that ship React Compiler output and expect the useMemoCache runtime. |
Custom Domains
- Add domain in Vercel Dashboard → Project → Domains
- Update DNS records (Vercel provides the values)
- SSL certificate is provisioned automatically
Then set AUTH_URL to the production domain and update payment/webhook callback URLs.
Preview Deployments & Health Checks
Every pull request gets a preview deployment ({project}-{hash}.vercel.app). Shipkit also ships a GitHub Actions health check that blocks promotion of deployments returning 5xx — see Vercel Deployment Checks for setup.
Production Checklist
- All required env vars set for Production environment
- Custom domain connected and DNS propagated
-
AUTH_URLmatches your production domain - Database is accessible from Vercel's network
- Webhook URLs updated to production domain
- Analytics configured (PostHog, Umami, etc.)
- Deployment health checks enabled (Settings → Deployment Checks)
- Local
bun run buildpasses after any upstream sync
Multi-Zone
For deploying multiple Shipkit apps under one domain, see the Multi-Zone guide.