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:

SettingValueSource
Framework presetNext.jsAuto-detected
Build commandbun run buildvercel.json
Install commandbun install --frozen-lockfilevercel.json
Dev commandbun run devvercel.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 78f5023abuildsout_of_memory
no20655 (26.7%)
yes260 (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:

  1. 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.
  2. out_of_memory is no longer automatically infrastructure. On a repo that already has turbopackFileSystemCacheForBuild: 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.
  3. 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 --webpack members 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_URL is derived from VERCEL_URL automatically; 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:

  1. / 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.
  2. 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.
  3. 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.
  4. 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:

  1. Build locally: bun run build. A route conflict fails fast with the two conflicting file paths in the error.
  2. 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.
  3. Check parallel slots: find src/app -type d -name "@*" — each needs a default.tsx.
  4. Check the lockfile: if the sync changed package.json, run bun install and 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:

SettingWhy it matters on Vercel
serverExternalPackagesExcludes native modules (isolated-vm), optional DB drivers, and ESM-only packages from the server bundle — these fail to compile or require() on Vercel.
transpilePackagesForce-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.bodySizeLimitRaises the Server Action payload cap for file uploads; the default rejects large uploads in production.
experimental.optimizePackageImportsTree-shakes icon/util libraries; reduces both build memory and function size.
outputFileTracingExcludesKeeps dev-only and heavy packages out of serverless function bundles — the main lever against the 50MB function limit.
outputFileTracingIncludesEnsures docs/** and src/content/** ship with functions that read them at runtime; without this, MDX pages 404/500 only in production.
reactCompilerRequired by dependencies (e.g. @payloadcms/ui) that ship React Compiler output and expect the useMemoCache runtime.

Custom Domains

  1. Add domain in Vercel Dashboard → Project → Domains
  2. Update DNS records (Vercel provides the values)
  3. 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_URL matches 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 build passes after any upstream sync

Multi-Zone

For deploying multiple Shipkit apps under one domain, see the Multi-Zone guide.