Deploy and observability — Vercel · Cloudflare Pages · Sentry · PostHog
Deploying Next.js to Vercel and Cloudflare Pages, preview deploys, environment variables, error tracking with Sentry, product analytics with PostHog. The toolkit for the first four weeks after launch.
Chapter 32 covered auth and sessions. This chapter is the last in Part 5 (Operations · Testing · Deploy); it looks at the tools for putting the app into a real production environment and operating it afterward.
Almost every introductory book covers “deploy,” but surprisingly few cover what comes after deploy. The first four weeks after launch are the roughest stretch. We do not know what error new users meet first, and we do not know where they drop off. Observability tools fill that gap. This chapter walks through one cycle of deploying to Vercel and Cloudflare Pages, tracking errors with Sentry, and starting product analytics with PostHog.
The toolkit in this chapter is used as is in the next chapter (Chapter 34, the fullstack Todo capstone). And the real production RUM data from Chapter 31 (Performance and Web Vitals) meets PostHog in this chapter.
The starting point for picking a host #
The two most universal choices for putting Next.js into production are Vercel and Cloudflare Pages. Both build preview deploys per PR automatically, integrate with GitHub, and allow rollback per commit.
The starting point of the choice has four parts.
- Traffic shape — is the app function-call-heavy, or is the static share large?
- Side-service dependence — is there value in tying yourself to Cloudflare services like KV / D1 / Workers?
- Cost curve — is the free tier sufficient, and what is the unit price beyond it?
- Operational convenience — Vercel is Next.js’s home; Cloudflare is a global edge.
The common choice for small side projects is start on Vercel → re-evaluate cost as traffic grows. This chapter follows that flow as well.
A Vercel cycle #
First deploy #
1. push the repo to GitHub
2. vercel.com → New Project → pick the repo
3. Framework: Next.js (auto-detected)
4. Environment Variables: AUTH_SECRET, AUTH_GITHUB_ID, AUTH_GITHUB_SECRET, etc.
5. click DeployWith almost no setup, a production URL appears within 1 ~ 2 minutes. Every push to the same repo creates a new deploy automatically, and every PR gets its own preview URL.
The value of preview deploys #
A new URL per PR is more than a convenience. Running the Playwright tests of Chapter 30 against the preview URL catches production-build-only bugs.
name: preview-e2e
on:
pull_request:
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps
- name: Wait for Vercel preview
uses: patrickedqvist/wait-for-vercel-preview@v1
id: preview
with:
token: ${{ secrets.GITHUB_TOKEN }}
max_timeout: 180
- run: pnpm exec playwright test
env:
PREVIEW_URL: ${{ steps.preview.outputs.url }}Open a PR, Vercel builds the preview, GitHub Actions waits for the URL to come alive, then runs Playwright. E2E running against the production build and the real database catches bugs invisible against a dev server.
Environment variables — split per environment #
Vercel splits env vars into three environments.
- Production:
mainbranch deploys - Preview: every PR / other branch deploy
- Development: local (
vercel env pullsyncs)
.env.local (gitignored, local development only)
↑
Vercel CLI: `vercel env pull` pulls the development env vars into .env.local
↓
Vercel web: enter values per Production / Preview / Development environmentSecrets like AUTH_SECRET should differ between production and preview (so an incident in preview does not bleed into production). One nuance: the OAuth callback URL changes dynamically with each preview URL, so configuring the OAuth App with a wildcard, or having a separate OAuth App for preview, is common.
Price and traps #
The free Vercel (Hobby) plan is sufficient for learning / personal projects. When you start putting production on it, check the following.
- Function Invocations: Server Action / API Route call counts. Free is 100k invocations / month.
- Image transforms (
next/image): free is 5,000 / month. Sites with heavy traffic hit the cap fast. - bandwidth: free 100GB / month.
- Commercial use: Hobby plan is non-commercial only. Moving to the Pro plan is required once revenue appears.
The image-transform cap on next/image is an unexpected trap. When the CDN cache empties and transforms run again, the cap drains quickly. Sites likely to see heavy traffic should consider running their own image hosting (Cloudflare R2 / Images, S3 + CloudFront) alongside.
A Cloudflare Pages cycle #
Running Next.js on Cloudflare #
Cloudflare Pages shines on static sites and also supports dynamic pages on the Workers runtime. To run Next.js with RSC + Server Actions on Cloudflare, use the @cloudflare/next-on-pages adapter.
pnpm add -D @cloudflare/next-on-pages{
"scripts": {
"build:cf": "next build && npx @cloudflare/next-on-pages",
"preview:cf": "wrangler pages dev .vercel/output/static"
}
}Some options in next.config.ts need to be set for Cloudflare compatibility (see the official docs for details).
Pairing with Workers / KV / D1 #
Cloudflare’s value lies in natural pairing with side services.
- KV — key-value store. Simple data like sessions or caches.
- D1 — SQLite-based serverless DB. The price is compelling.
- R2 — S3-compatible object storage. Free egress.
- Images — image transforms + CDN. Substantially raises the
next/imagecap.
Calling these side services from inside the same Workers runtime makes latency extremely low. If you want a globally edge-distributed app, Cloudflare has the advantage.
Price comparison #
| Item | Vercel Hobby (free) | Cloudflare Pages free |
|---|---|---|
| Bandwidth | 100GB/month | unlimited |
| Function Invocations | 100k/month | 100k requests/day |
| Build minutes | 6,000 min/month | 500 min/month |
| Side services | separate | KV / D1 / R2 integrated |
The numbers shift often as policies change, so reconfirm at launch time.
A trap — file multiplication by tool #
Cloudflare Pages caps the number of files per deploy (20,000 at the time of writing). Static site generators like Hugo / Next.js static export can multiplicatively create pages per category / tag, so even 200 posts can produce tens of thousands of actual files. Assuming free-tier limits without checking ahead can leave you stuck right before launch.
This is a trap directly hit while operating schoolofweb.net. In environments with a static-site tool plus category / tag / series multipliers, count the post-build files first, then pick the host.
Decision tree for picking a host #
Next.js fullstack app?
├── Yes
│ ├── Need a global edge? (worldwide users)
│ │ ├── Yes → Cloudflare Pages + Workers
│ │ └── No
│ │ ├── Within Vercel limits to operate?
│ │ │ ├── Yes → Vercel
│ │ │ └── No → Cloudflare Pages or self-host
└── No (static site)
└── Cloudflare Pages or Netlify or GitHub PagesThe most common flow is start on Vercel, move to Cloudflare if traffic / cost becomes a problem. Both hosts take Next.js as their standard input, so the migration cost is small (environment variables / OAuth callback URL, mostly).
Environment variables and secrets management #
Build time vs run time #
Next.js environment variables are evaluated at two points.
- Build time: variables prefixed with
NEXT_PUBLIC_. Inlined into the code at build time. - Run time: other variables. Accessed via
process.envwhile the server code runs.
// build time (exposed to client)
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
// run time (server only, not bundled into client)
const dbUrl = process.env.DATABASE_URL;
const authSecret = process.env.AUTH_SECRET;The NEXT_PUBLIC_ prefix is a declaration of intent that “this value is safe to ship in the client bundle”. Never attach it to a secret.
Verifying secret exposure #
After a production build, a habit of grepping the files in .next/static/chunks/ for secrets is a safe check.
pnpm build
grep -r "AUTH_SECRET\|<part of the actual secret>" .next/staticThere should be no matches. If Chapter 32’s AUTH_SECRET slipped into the production bundle, the standard response is to immediately rotate the OAuth provider’s secret.
Sentry — error tracking #
A tool that automatically collects errors from production and shows them in one place. It captures location, stack trace, user info, and the actions just before (breadcrumbs).
Install and setup #
pnpm add @sentry/nextjs
pnpm exec @sentry/wizard@latest -i nextjsThe wizard automatically creates sentry.client.config.ts / sentry.server.config.ts / sentry.edge.config.ts and wraps next.config.ts.
What is tracked automatically #
Installation alone tracks the following automatically.
- Uncaught JavaScript exceptions (both client and server)
- Unhandled Promise rejections
- Errors in Server Actions / API Routes
- Errors caught by a React error boundary
import * as Sentry from '@sentry/nextjs';
try {
await riskyOperation();
} catch (err) {
Sentry.captureException(err, { tags: { feature: 'payment' } });
throw err;
}Sending tags and context along makes dashboard filtering easier.
Source map upload #
Production builds are minified, so stack traces look like (anonymous):1:23456. Uploading source maps to Sentry maps them back to the original code locations. The wizard configures this part automatically.
Automatic masking of secret info #
By default, fields like password, token, and secret are masked automatically. If you have custom field names, add them explicitly.
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
beforeSend(event) {
if (event.request?.data?.creditCard) {
delete event.request.data.creditCard;
}
return event;
},
});beforeSend is the hook where you can shape the event one more time before it ships.
Alerts and priority #
In the Sentry dashboard, set up Slack / email notifications when the same error (by fingerprint) occurs N times. An immediate alert for every error is noise. The usual starting point looks like this.
- A new error appears in production for the first time → immediate alert
- The same error occurs more than 100 times per hour → immediate alert
- Otherwise → daily summary
PostHog — product analytics #
Where Sentry asks “what went wrong,” PostHog asks “what are users doing.” It collects data like drop-off points, funnel conversion, and feature usage frequency.
Install #
pnpm add posthog-js posthog-node'use client';
import posthog from 'posthog-js';
import { PostHogProvider } from 'posthog-js/react';
import { useEffect } from 'react';
if (typeof window !== 'undefined') {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST,
person_profiles: 'identified_only',
});
}
export function Providers({ children }: { children: React.ReactNode }) {
return <PostHogProvider client={posthog}>{children}</PostHogProvider>;
}Balancing autocapture and explicit events #
One of PostHog’s strengths is autocapture — every click and pageview is collected automatically. Setting it up makes funnel analysis available immediately.
Still, autocapture is not all-encompassing. Actions with user intent (sign-up completed, payment completed, core feature usage) are best caught as explicit events for reliability.
'use client';
import posthog from 'posthog-js';
function CheckoutButton() {
function handleClick() {
posthog.capture('checkout_completed', {
amount: 9900,
currency: 'KRW',
plan: 'pro',
});
// ... process payment
}
return <button onClick={handleClick}>Checkout</button>;
}Pairing with Web Vitals from Chapter 31 #
Sending the Web Vitals data collected via useReportWebVitals in Chapter 31 to PostHog gives you the LCP / INP / CLS distribution of real users on the dashboard.
'use client';
import { useReportWebVitals } from 'next/web-vitals';
import posthog from 'posthog-js';
export function WebVitals() {
useReportWebVitals(metric => {
posthog.capture('web_vital', {
name: metric.name,
value: metric.value,
rating: metric.rating,
});
});
return null;
}Looking at LCP p75 / p95 over time on PostHog’s insights view shows the real user distribution, which differs from the lab data we saw in Chapter 31.
Feature flags #
PostHog has feature flags as a side feature. Use them when exposing a new feature to a subset of users or running an A/B test.
'use client';
import { useFeatureFlagEnabled } from 'posthog-js/react';
function PricingPage() {
const newPricing = useFeatureFlagEnabled('new-pricing-page');
return newPricing ? <NewPricing /> : <OldPricing />;
}Toggle flags per user group from the PostHog dashboard. You can adjust exposure ratios without redeploying code.
CI integration — Part 5 as one flow #
Let us draw the CI flow that ties together every tool from Chapters 29 ~ 33.
1. lint + type check (immediately)
2. Vitest unit / integration tests (Chapter 29)
3. Next.js build (production build)
4. Vercel preview deploy (automatic)
5. Playwright E2E (against preview URL, Chapter 30)
6. Lighthouse CI (against preview URL, Chapter 31)
7. PR review + merge
8. merge to main → production deploy
9. source maps auto-uploaded to Sentry / PostHogEach step has to pass before moving on. Merged code has already been validated against a production build once.
This flow is the foundation for not fearing the four weeks after launch. Before new code reaches production, it passes an automated safety net; once it lands, Sentry / PostHog tells you what is happening.
A checklist for the first four weeks after launch #
As a way to close Part 5, here is a list of items frequently hit during the first four weeks after launch.
- First 24 hours: monitor whether Sentry alerts work, and whether new errors pile up too quickly. Check on PostHog whether autocapture alone draws a funnel.
- First week: check the real-user distribution of Web Vitals (can differ from lab). Is the p75 of LCP / INP in the “Good” range?
- Second week: pick the three most common errors and handle them first. Add retries / fallbacks / clearer messages so the new errors do not occur.
- Third week: analyze the user funnel. Identify the heaviest drop-off point with PostHog. Improve UX or performance at that step.
- Fourth week: limit / cost check. Are Vercel function invocations, image transforms, and bandwidth within the free tier? Re-evaluate hosting / image hosting if needed.
Try it yourself — running the full cycle once #
Pick this book’s example app or a small project of your own and run the following through end to end.
- Deploy to Vercel: GitHub repo → Vercel import → enter env vars → production deploy.
- Verify preview deploy: create a new branch, change one line, open a PR. Confirm a preview URL is created automatically.
- Playwright on preview: add the
preview-e2e.ymlabove, push the PR once more, and confirm CI runs E2E against the preview URL. - Sentry setup: install via the wizard, then make a page that intentionally throws (
throw new Error('test')). Deploy to production, hit the page, and watch the Sentry dashboard show a stack trace mapped back through source maps. - PostHog setup: add the Providers above and send an explicit event for sign-up or one core action. Draw a funnel once on the PostHog dashboard.
- Web Vitals → PostHog: wire the
useReportWebVitalsfrom Chapter 31 to PostHog and confirm the real user distribution flows in.
After these six steps, every tool from Part 5 connects naturally into a single flow.
Exercises #
- Vercel vs Cloudflare choice. Answer which host fits each of the following three apps and why. (a) A small single-domain SaaS expecting roughly 100k pageviews / month, mostly Korean users, (b) a video meta site aimed at worldwide users with potential for sharp traffic growth, (c) a static markdown blog with 5,000 posts and category / tag multipliers.
- NEXT_PUBLIC_ vs plain variable. Answer whether each of the following variables should be prefixed with
NEXT_PUBLIC_. (a) Stripe publishable key, (b) Stripe secret key, (c) PostHog project API key, (d) DATABASE_URL, (e) Sentry DSN. After answering, refer to the build time vs run time section. - Splitting errors vs analytics. For the following five pieces of data, answer which tool (Sentry / PostHog) is the natural home. (a) An unhandled error from a Server Action, (b) the sign-up completed event, (c) Web Vitals distribution in production, (d) checkout funnel conversion, (e) a client JS exception. For items that could go either way, write one line on which is more appropriate.
In one line: a Next.js fullstack app typically starts on Vercel and moves to Cloudflare Pages as traffic / cost dictates, and the preview deploy per PR is the core safety net that catches production-build-only bugs. The
NEXT_PUBLIC_prefix is an “I declare this value safe for the client” mark; secrets never get it. Sentry gathers production errors with source maps in one place, and PostHog catches funnels / Web Vitals distribution through autocapture and explicit events. Tying the tools of Chapters 29 ~ 33 together as one CI flow makes the first four weeks after launch unscary.
Next chapter #
This chapter completes Part 5 (Operations · Testing · Deploy). The next chapter, Chapter 34 Building the Fullstack Todo App, opens Part 6 (Capstone). Chapter 34 weaves every tool built in Chapters 1 ~ 33 into one small fullstack app. RSC + Server Actions + auth + DB persistence + tests + deploy + observability — the whole arc of the book converges into a single running service.