Next.js + Vercel
A Next.js 14 app (App Router) deployed to Vercel with @muthuishere/vsync-s3-client reading secrets from S3 on server-side rendering and API routes. Two env vars in Vercel's Sensitive Variables.
Stack
- App: Next.js 14 (App Router) + React 18
- Platform: Vercel
- Vault: S3-compatible bucket
- Lib:
@muthuishere/vsync-s3-client0.11.0
Working directory tree
my-next-app/
├── app/
│ ├── page.tsx (server component — uses vsync directly)
│ ├── api/
│ │ ├── health/route.ts (health endpoint with has_new_version)
│ │ └── data/route.ts (your API)
│ └── layout.tsx
├── lib/
│ └── vsync.ts (the lazy singleton wrapper)
├── infra/
│ └── vault/ (gitignored)
│ └── prod/
│ └── .env.prod
├── package.json
├── tsconfig.json
├── next.config.mjs
└── .gitignoreOne-time setup
1. Init env + push
cd my-next-app
vsync profile add acme-prod # if not already
vsync init prod --profile=acme-prod
cat > infra/vault/prod/.env.prod <<'EOF'
DATABASE_URL=postgres://...
STRIPE_KEY=sk_live_...
NEXT_PUBLIC_BASE_URL=https://app.myapp.com
SENTRY_DSN=https://...@sentry.io/...
EOF
vsync push prod --note="initial prod setup"Note about NEXT_PUBLIC_* keys: Next.js inlines these into the client bundle at build time. Putting them in vsync means they're available at server boot but not in the browser unless you read them server-side and pass through props. See things to watch out for.
2. Mint the runtime token
vsync runtime-token --env=prod \
--access-key=AKIA_PROD_READONLY \
--secret-key=PROD_READONLY_SECRET
# → vsync-cfg-v1:H4sIAAAA...3. Paste into Vercel
Settings → Environment Variables:
VSYNC_CONFIG— paste the blob, mark Sensitive, scope Production.VSYNC_PASSPHRASE— paste the passphrase, mark Sensitive, scope Production.
For preview deployments, repeat with the preview-env values (or omit, and let preview fall through to defaults).
Code
package.json (excerpt)
{
"dependencies": {
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"@muthuishere/vsync-s3-client": "^0.11.0"
}
}lib/vsync.ts — the singleton wrapper
import { open, type Vsync } from "@muthuishere/vsync-s3-client";
let _handle: Vsync | null = null;
let _pending: Promise<Vsync> | null = null;
export async function vsync(): Promise<Vsync> {
if (_handle) return _handle;
if (_pending) return _pending;
_pending = open({
defaults: {
NEXT_PUBLIC_BASE_URL: "http://localhost:3000",
},
}).then((v) => {
_handle = v;
return v;
});
return _pending;
}Why the _pending promise: in a Vercel serverless function, multiple concurrent requests on the same warm instance shouldn't race to call open() four times. The first request kicks off the promise; subsequent ones await the same promise; once resolved, _handle is cached for all future calls.
app/page.tsx — server component
import { vsync } from "@/lib/vsync";
export default async function Page() {
const v = await vsync();
// env_source is safe to render — never the value
return (
<main>
<h1>Hello</h1>
<p>DB source: {v.envSource("DATABASE_URL")}</p>
<p>Gen: {v.generation()}</p>
</main>
);
}Never render v.getEnv("DATABASE_URL") directly — that would inline the secret into the HTML you ship to the browser.
app/api/health/route.ts
import { vsync } from "@/lib/vsync";
import { S3UnreachableError, ManifestNotFoundError } from "@muthuishere/vsync-s3-client";
export async function GET() {
const v = await vsync();
try {
if (await v.hasNewVersion()) {
return Response.json({
status: "stale",
localGen: v.generation(),
remoteGen: await v.remoteGeneration(),
});
}
return Response.json({ status: "fresh", gen: v.generation() });
} catch (err) {
if (err instanceof S3UnreachableError || err instanceof ManifestNotFoundError) {
return Response.json({ status: "unknown", gen: v.generation() });
}
throw err;
}
}app/api/data/route.ts
import { vsync } from "@/lib/vsync";
export async function GET() {
const v = await vsync();
const dbUrl = v.getEnv("DATABASE_URL");
if (!dbUrl) {
return Response.json({ error: "DATABASE_URL not configured" }, { status: 500 });
}
// ... fetch from your DB ...
return Response.json({ ok: true });
}Deployment
vercel --prodVercel reads VSYNC_CONFIG and VSYNC_PASSPHRASE from the dashboard's Sensitive Variables and injects them at function invocation. No deployment manifest changes needed.
For local dev:
# Pull the dev vault locally
vsync pull dev
vsync use dev
# Then:
npm run dev
# Reads from ./.env (symlinked) via Next.js's built-in dotenv support — no vsync at allAfter rotation — Vercel specifics
# 1. Rotate the passphrase
vsync rotate-passphrase --env=prod
# 2. Update VSYNC_PASSPHRASE in Vercel dashboard
# 3. Trigger a redeploy (env-var change alone doesn't auto-redeploy Sensitive Variables)
vercel --prodOr for IAM rotation:
vsync runtime-token --env=prod --access-key=AKIA_NEW --secret-key=NEW_SECRET
# Copy the output, paste into Vercel's VSYNC_CONFIG Sensitive Variable, redeploy.Vercel's deploy model favours rolling cutovers — the old deployment stays alive serving traffic until the new one is healthy. Race window is small.
Things to watch out for
NEXT_PUBLIC_*keys in the vault. Next.js inlinesprocess.env.NEXT_PUBLIC_*into client bundles at build time. If your build doesn't haveNEXT_PUBLIC_*set in the build environment, the client bundle will haveundefinedbaked in.Fix: for client-exposed config, set the value as a regular Vercel env var (not Sensitive) so it's available at build time. Use vsync only for server-side secrets. Or: in your server components, read from
v.getEnv("NEXT_PUBLIC_BASE_URL")and pass through props to client components.Edge runtime doesn't support
@muthuishere/vsync-s3-client. The library uses Node APIs (node:crypto,node:fsfor_FILEpaths). Edge runtime is a stripped-down V8. Stick to Node runtime for routes that use vsync:typescript// In your route file: export const runtime = "nodejs"; // not "edge"Multiple Vercel deployments running in parallel (e.g. during a rollout) may have different vsync gens for a brief window. Healthcheck reports
staleuntil everything converges. Add jitter / retries on the orchestrator side if you alert onstalestatus.Sentry's
process.envcapture will pick upVSYNC_CONFIGandVSYNC_PASSPHRASE. Configure the SDK to redact:typescriptSentry.init({ beforeSend(event) { if (event.extra) { delete event.extra.VSYNC_CONFIG; delete event.extra.VSYNC_PASSPHRASE; } return event; }, });Build-time vs. runtime.
next buildruns on Vercel's build machines, not your runtime container. The vsync env vars aren't available at build time unless you also configure them for the build environment. For most apps this is fine — vault values are read at request time, not build time. But if you do anything ingetStaticPropsorgenerateStaticParamsthat needs a secret, you'll need a different bootstrap.App Router caching. Server components are cached aggressively. If a route renders
v.envSource(...)(or, dangerously,v.getEnv(...)) into HTML, that HTML may be cached and served across requests. Useexport const dynamic = "force-dynamic"on routes that depend on per-request vsync state.
Local development
The simplest path is vsync use dev + Next.js's built-in dotenv. Next.js reads ./.env, ./.env.local, etc. automatically:
vsync pull dev
vsync use dev # ./.env → infra/vault/dev/.env.dev
npm run devIf you want to use the vsync runtime lib in local dev (e.g. to test the boot path), use the _FILE variant:
# In your shell:
export VSYNC_CONFIG_FILE=/tmp/vsync-dev-config
export VSYNC_PASSPHRASE_FILE=/tmp/vsync-dev-passphrase
echo "$DEV_CONFIG_BLOB" > $VSYNC_CONFIG_FILE
echo "$DEV_PASSPHRASE" > $VSYNC_PASSPHRASE_FILE
chmod 0600 $VSYNC_CONFIG_FILE $VSYNC_PASSPHRASE_FILE
npm run devMost teams don't bother — Next.js's ./.env flow is fine for dev. Vsync is for deployed envs.
Where to go next
- TypeScript lib reference: TypeScript
- Other TS recipe: Express + Fly.io
- Mint a token: Runtime tokens
- Rotate the passphrase: Rotate-passphrase runbook