Shipping a Next.js 15 app to Vercel: a 2026 field guide
The playbook I now follow every time I take a Next.js 15 project from pnpm dev to a green Vercel deploy: runtime contracts, caching, server actions, bundle size, and smoke tests.
Sep 3, 2026 · ~2 min read
Shipping a Next.js 15 app to Vercel: a 2026 field guide
Next.js 15 changed a few things I had to relearn the hard way. Here is the
playbook I now follow every time I take a project from pnpm dev to a
green Vercel deploy.
1. Lock down the runtime contract first
The biggest surprise in 2026 is that the edge is the default, not an
optimization. Every page, every route handler, every Server Action is now
expected to run on the Edge runtime unless you opt out with
export const runtime = "nodejs". Audit your dependencies before you ship —
anything that pulls in node:crypto, node:fs, or pg needs the Node
runtime.
ts
// app/api/cron/route.ts
export const runtime = "nodejs"; // pg is not edge-compatible
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
```
## 2. Cache is not a knob, it is a contract
Next.js 15 deprecated the old `revalidate: 60` on `fetch`. The replacement
is `unstable_cache` (now stable as `cache`) plus tags. Every expensive
query gets a tag, every mutation invalidates the right tags.
```ts
import { cache } from "react";
export const getProjects = cache(async () => {
const cached = await unstable_cache(
async () => db.project.findMany(),
["projects"],
{ tags: ["projects"] },
)();
return cached;
});
```
## 3. Server Actions are just endpoints
Treat them like API routes: validate the input, check auth inside the
action, and don't trust the layout guard. The CONSTRAINTS doc calls this out
explicitly; I learned to honor it after one too many `curl localhost:3000`
calls that bypassed the UI.
```ts
"use server";
export async function deleteProject(id: string) {
const session = await getSession();
if (!session?.user) throw new Error("Unauthorized");
await db.project.delete({ where: { id } });
revalidateTag("projects");
}
```
## 4. Bundle size is a feature
Initial JS budget for this site is **180 KB gzipped**. That is tight. The
biggest wins:
- `next/dynamic` for anything that isn't above the fold
- Direct imports for icon libraries (`lucide-react` ships a barrel
file that costs 200-800ms cold-start by itself)
- `optimizePackageImports` in `next.config.js` for the standard culprits
## 5. Ship a smoke test before you trust the deploy
I run a single Playwright test on every preview deployment: load the
landing page, click into the projects list, click into a project detail,
fill out the contact form. If those four steps work, I trust the build.
```ts
test("smoke", async ({ page }) => {
await page.goto("/");
await page.click("text=projects");
await page.click("text=hadaksa.com");
await page.click("text=contact");
});
```
That's the whole guide. The TL;DR: **the edge is the default, cache is a
contract, actions are endpoints, budget your JS, and write the smoke test
first**.