A Next.js deployment adapter, and the runtime that serves what it builds.
Your own next build writes one deployment bundle for a host to deploy: the routing tables, every prerender and static file named by a hash of its content, and the Functions that run your code. Your app stays plain Next.js.
Quick start · Why upwind · vs. Next.js · Packages · FAQ · Contributing
English · 日本語
Important
upwind is before 1.0. The deployment bundle carries a version of its own and its shape is expected to change before it settles, and no release keeps a compatibility layer for the previous minor version. The most useful thing you can send is a build we have never seen: tell us what your app does that upwind does not serve yet.
upwind does two jobs around your Next.js app: it turns next build into a deployment, and it puts a front door in front of the dev server — a thin layer on the same port that answers upwind's own /__upwind paths and hands every other request to Next.js.
|
🔨 Your own |
🚪 Your own Next.js dev server — the same Turbopack, HMR and error overlay — behind that front door, which answers |
🌱 A Next.js 16 app with Tailwind 4, wired to both commands, installed, and committed. There is one template, so the only question it asks is where to put the app. |
flowchart TB
subgraph build ["upwind build · on your machine or in CI"]
direction LR
app["Your Next.js app"] --> nb["next build · Turbopack"]
nb -- "Adapter API" --> ad["@stayingupwind/adapter"]
end
subgraph bundle [".arkor/ · one directory"]
direction LR
bj["bundle.json"] ~~~ bl["blobs, named by their content"] ~~~ fa["app Function"] ~~~ fm["middleware Function<br/>when there is a proxy"]
end
subgraph serve ["a host, at request time"]
direction LR
edge{{"edge"}} -- "static files · prerendered shells" --> st[("storage")]
edge -- "proxy.ts" --> mw["middleware Function"]
edge -- "rendering · PPR resume" --> fn["app Function"]
end
build --> bundle
bundle -- "uploaded by upwind deploy, or by a host" --> serve
Note
upwind deploys the directory, too — to a Cloudflare account of your own. upwind deploy builds the app, uploads .arkor/, puts the edge in front of its Functions and the runtime cache behind them, and switches the site to the new deployment once it answers. upwind preview serves a build the same way on your machine, and deploys nothing. upwind deployments lists what is deployed, upwind rollback goes back to an earlier deployment, and upwind destroy deletes everything deploy made. upwind's readme has the details. A host of another kind can take the directory instead: what it implements — the bundle's schema, the manifest an edge reads, and the x-arkor-* headers an edge and a Function exchange — is specified in @stayingupwind/core.
- Next.js teams who want a deployment bundle for their app without porting it: App Router or Pages Router, Partial Prerendering and
"use cache", Server Actions,next/image,next/og, WebAssembly — built by thenext buildyou already run, and served by a host. - Teams who want the build to be the artifact. CI produces one directory you can inspect, diff and keep, and that directory is what a host deploys.
- Hosts and platform builders who want to serve Next.js. The bundle is a versioned schema, the edge ↔ Function protocol is a set of
x-arkor-*headers, and the Functions are built from@stayingupwind/runtime— nothing to reverse-engineer out of.next/.
Not a fit yet if…
- you build with webpack. It is not supported; see what it serves.
- you deploy somewhere other than a Cloudflare account of your own. That is the one place
upwind deploydeploys to — with custom domains attached by hand, and no cron jobs yet; anywhere else needs a host that takes the bundle. See the note above. - you need 1.0 guarantees. See the status note.
You need Node.js 24 or later. The commands use pnpm; create-upwind also installs with npm, Yarn or Bun (below).
pnpm create upwind my-app
cd my-app
pnpm dev upwind 0.4.0 dev
- Local: http://localhost:3000
- Internal: http://localhost:3000/__upwind
✓ Next.js 16.4.3 ready in 1127msOpen http://localhost:3000 and edit app/page.tsx — it is the Next.js dev server you already know. To build the deployment bundle:
pnpm build # upwind build: your own next build, with the adapter plugged in → the bundleAnd to serve that bundle on your machine as it is deployed — an edge in front, the runtime cache behind it:
pnpm preview # upwind preview: the build, at http://localhost:3000; it builds nothingTip
Open http://localhost:3000/__upwind to see what upwind knows about the run: the versions, the address, the project directory, and the adapter it resolved. /__upwind/health answers 200 once Next.js is ready and 503 until then, for a script that waits for the server.
With npm, Yarn or Bun
npm create upwind@latest my-app
yarn create upwind my-app
bun create upwind my-appDependencies are installed with the package manager you ran create upwind with; --use-npm, --use-pnpm, --use-yarn or --use-bun picks another. --skip-install writes the app and installs nothing, and --no-git skips the first commit.
What create-upwind writes
my-app
├── .gitignore
├── AGENTS.md # Next.js's own agent rules, word for word
├── app/
│ ├── globals.css # @import "tailwindcss";
│ ├── layout.tsx
│ └── page.tsx
├── next.config.ts # sets adapterPath, so a plain `next build` writes the same bundle
├── package.json # "dev": "upwind dev", "build": "upwind build", "preview": "upwind preview"
├── postcss.config.mjs
├── tsconfig.json
└── README.md
next, react and react-dom are the app's own dependencies, and upwind and @stayingupwind/adapter are asked for at create-upwind's own version. There is no ESLint, no src/ and no component library — create-next-app --empty is the shape — and no start script, on purpose.
Your app needs Next.js in the supported range, built with Turbopack.
-
Install the CLI and the adapter:
pnpm add -D upwind @stayingupwind/adapter
-
Point the
devandbuildscripts at upwind:{ "scripts": { "dev": "upwind dev", "build": "upwind build" } } -
Recommended: set
adapterPathinnext.config.tstoo, so that a plainnext build— from CI, a script, anything that has never heard of upwind — writes the same bundle:import { createRequire } from 'node:module'; import type { NextConfig } from 'next'; const config: NextConfig = { adapterPath: createRequire(import.meta.url).resolve('@stayingupwind/adapter'), }; export default config;
In a CommonJS
next.config.js,require.resolve('@stayingupwind/adapter')does the same. If the same app also deploys to Vercel, setadapterPathonly when theVERCELenvironment variable is unset: an adapter named innext.configtakes over the build on Vercel too. upwind's readme shows the conditional form.
Then add .arkor/, which a build writes, and .upwind/, where local storage is kept, to .gitignore.
pnpm upwind build --target=node
pnpm upwind start --port 3000This opt-in target writes .arkor/node/: a Node server, the application's traced dependencies,
static files, and a persistent cache for one server process. It needs no Cloudflare account,
Miniflare, or workerd. The default build and Cloudflare deployment continue to use Workers.
Copy .arkor/node/ to another directory and run node server.mjs there to serve it directly.
Native addons retain the build machine's operating-system and architecture requirements.
Use upwind start --origin https://app.example --cache-dir /var/lib/upwind behind a proxy;
the configured origin is trusted, while client-supplied forwarding headers are discarded.
See the native server options and supported features.
|
🧩 It is still your Next.js. No fork and no wrapper API: the adapter plugs into Next.js's stable Adapter API, and |
🔒 Builds need no account and no credentials. The adapter calls no service while it writes the bundle. |
|
📦 Deploys by content. Every prerendered shell and static file is a blob named by a hash of its content, so a file that did not change keeps its name from one deploy to the next, and a host need not upload it again. |
⚡ Partial Prerendering from storage. The edge sends a route's prerendered shell at once, and the Function sends only the dynamic part the build left for request time — no visitor is sent the shell twice. |
|
🚦 Middleware that need not wake your app. |
🧮 WebAssembly, compiled once. A |
|
🛑 Fails in the build, not after it. Every Function is audited — over the size limit, an import the deployment runtime is not known to provide, a |
🔭 Held to Next.js every day. Where the Adapter API is not enough, the adapter patches Next.js's own output, and every patch is checked daily against real Next.js releases, from the oldest supported to the canary. How. |
|
🩺 Answers when nothing else does. |
🔏 Releases you can verify. Every release is published by CI from a signed tag on |
|
💾 Storage with nothing to configure |
🤖 Ready for coding agents |
upwind does not replace Next.js — it runs yours. It carries no copy of the framework: upwind dev and upwind build use the Next.js your project depends on, and refuse to start without one. What changes is what surrounds your app — the front door in development, and what a build becomes in production.
| Next.js on its own | Next.js with upwind | |
|---|---|---|
| Your code | App Router, Pages Router, next.config |
The same. Nothing to port beyond what is not supported, and nothing you have to import from upwind |
| Development | next dev |
upwind dev: the same dev server, in the same process, behind a front door that answers /__upwind |
| Build | next build → .next/ |
upwind build: the same next build, whose adapter also writes .arkor/ |
| Production | next start: one long-running Node.js server |
No server of your own: an app Function — several, split along what the routes share, for an application too large for one, where the host splits — and a middleware one when there is a proxy, behind a host's edge |
| Static files and prerenders | Served by that server | Blobs named by content, served from storage by the edge |
| Partial Prerendering | The server sends the shell, then streams the rest | The edge sends the shell from storage; the Function sends only the dynamic part |
Middleware / proxy.ts |
Runs inside the server | Also a Function of its own, which an edge can run before waking the application |
/_next/image |
Optimized by the server | Optimized by the edge |
Cache (ISR, "use cache", fetch) |
In memory and on disk, or a cacheHandler you write |
Kept by the host, through a cache module named when the bundle is built: upwind build names upwind's runtime cache, which upwind preview and upwind deploy serve; a plain next build names none, and its bundle caches nothing |
| Cron jobs | — | crons in upwind.config.ts or vercel.json, checked at build time |
| Local storage | — | D1, KV and R2 under .upwind/, read through @stayingupwind/sdk |
| Bundler | Turbopack or webpack | Turbopack |
Leaving takes one diff. Put next dev and next build back in package.json, and remove the two devDependencies and the adapterPath line. What you would have to replace is code that reads storage through @stayingupwind/sdk, and any crons in upwind.config.*.
How upwind dev wraps next dev
flowchart LR
client["browser · curl · tools"] --> door["upwind dev<br/>holds the port"]
door -- "/__upwind · /__upwind/health" --> own["answered by upwind"]
door -- "every other request" --> nx["your Next.js dev server<br/>same process · Turbopack · HMR"]
door -. "WebSocket upgrade" .-> nx
upwind listens, and Next.js runs in the same process behind it, started the way Next.js documents a custom server — so there is no second server and no proxy hop in between. What Next.js's own parent process does, upwind dev does too: it prints the banner, and starts the server again when next.config changes or the error overlay's restart button is pressed.
Apart from --port and --hostname, next dev's flags — --experimental-https, --inspect, --turbopack, --webpack — are refused rather than quietly ignored. A request for /__upwind/… can also reach Next.js's router from inside — a middleware that rewrites to it, a request Next.js makes of itself — so the adapter, when it is installed, reserves the prefix in Next.js's router too, before any of your routes are matched, and a catch-all route of yours cannot answer it. Under a plain next dev, with no front door to send it to, nothing is reserved.
| Feature | Notes | |
|---|---|---|
| ✅ | App Router: Server Components, streaming, Server Actions | |
| ✅ | Partial Prerendering, Cache Components | the shell from storage, the rest from the Function |
| ✅ | "use cache", ISR, revalidateTag / updateTag, cached fetch |
kept by a host's cache module, named at build time (below) |
| ✅ | A cacheHandler / cacheHandlers of your own |
bundled into a Function (not a static export's); see below |
| ✅ | Pages Router: getStaticProps, getServerSideProps, API routes |
including the _next/data outputs the client router asks for |
| ✅ | proxy.ts, and the deprecated middleware.ts |
also a Function of its own |
| ✅ | Routes with export const runtime = 'edge' |
served as built, or rendered in full on each request |
| ✅ | next/image |
optimized at the edge |
| ✅ | next/og |
drawn by the build, or by the Function where the route is dynamic |
| ✅ | WebAssembly | compiled once, at upload |
| ✅ | instrumentation.ts, Draft Mode |
|
| ✅ | basePath, trailingSlash, i18n |
|
| ✅ | Static export (output: 'export') |
the same bundle, with the server parts empty |
| ✅ | Cron jobs | declared beside next.config |
| ✅ | The Workflow SDK ("use workflow", workflow 5.x) |
its flow route a Function of its own; the World a host's (below) |
The cache is a host's, and it is chosen at build time. The runtime hands every cache read and write — save those an application's own handler takes (below) — to a module the host names in an adapter of its own, createAdapter({ cacheHostModule }). The default adapter names none of its own; upwind build — what the steps above run — names upwind's runtime cache to it through the environment, so its bundle keeps a cache wherever one is bound: under upwind preview, and deployed by upwind deploy. Where none is bound, and from a plain next build, a bundle serves what the build produced and revalidates nothing.
An application's own handlers sit beside it. A build that configures cacheHandler or cacheHandlers has each module bundled into the Function, and says so in a warning; a static export, which has no Function to run one, bundles none. Each takes what Next.js hands it there: a cacheHandler the data cache, fetch and unstable_cache — on a Function Next.js keeps no page in it, so pages stay the host's — and a kind of cacheHandlers that kind of "use cache". revalidateTag, revalidatePath and updateTag still reach the host's cache. A handler runs in each isolate of the Function, so what it keeps in memory is that isolate's alone; a store every isolate reaches is what makes a cache shared. And what a host's cache module does beside the cache is not done for one: a write is not held to land before the response ends, and a value Next.js answers stale is revalidated behind it only as far as the request's work goes on. What a handler imports is bundled with it; a file it reads at run time is not carried, and an ES module that awaits at its top level is refused, since the Function's bundle is CommonJS.
So is a workflow's World. A build that uses the Workflow SDK runs its workflows and steps in a Function of their own, on the SDK's QuickJS engine, and keeps its runs in a World the host names, createAdapter({ workflowWorldModule }): the host delivers the SDK's queue to that Function, and nothing a visitor sends reaches it. The adapter's readme says what the host provides.
Cloudflare Workflows are supported separately. The adapter produces isolated native class artifacts, the SDK exposes scoped instance controls, and upwind dev and upwind preview run the classes in Miniflare. Hosted execution and resource isolation are supplied by a supporting host; see the adapter contract.
Not supported
- webpack builds. Turbopack only; a static export is taken from any bundler, since it carries no built code.
- Revalidating a route on the edge runtime. Its outputs are served as built, or rendered in full on each request.
experimental.optimizeCss. It reads built stylesheets off a disk a Function does not have; the first render that needs it fails, and says why.partialFallback. Recorded in the bundle, not acted on.experimental.runtimeServerDeploymentId.
The adapter's readme lists every field it reads from Next.js and what that field becomes in the bundle. The patches it applies to Next.js's output are listed in packages/adapter/src/patches/index.ts.
An app gets a D1 database, a KV namespace and an R2 bucket without writing a line of configuration. Locally, upwind dev and upwind build run them, keep their data in .upwind/, and publish them to your code. In a deployment, the Function publishes the storage the host attached. The SDK reads what was published, the same way in both.
import db from '@stayingupwind/sdk/db';
export default async function Page() {
const { results } = await db
.prepare('select id, title from posts order by id desc')
.all<{ id: number; title: string }>();
return (
<ul>
{results.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}| Import | What it is |
|---|---|
import db from '@stayingupwind/sdk/db' |
the project's D1 database, if exactly one is published |
import kv from '@stayingupwind/sdk/kv' |
the project's KV namespace, likewise |
import blob from '@stayingupwind/sdk/blob' |
the project's R2 bucket, likewise |
d1(name), kv(name), blob(name) |
storage by the name it was bound under |
published() |
everything published, and the names it is published under |
- One of a kind needs no name. When exactly one D1 database is published,
dbis that database; with several it throws and lists them, andd1('ORDERS')picks the one you mean. - The SDK includes the required storage types —
D1Database,KVNamespace,R2Bucket, imported from@cloudflare/workers-types— so notsconfigof yours needs to know about them. - Prerendering can read it. In a project with the SDK installed,
upwind buildpublishes the same storage while pages prerender, sogenerateStaticParamscan read its slugs out of D1. One directory of storage can be open in only one runtime at a time, so pages are then prerendered by one build worker rather than several — unless the project setsexperimental.cpusitself, in which case a page that reads storage fails in every worker but one. A plainnext buildpublishes none, and the SDK throws an error saying so rather than guessing. - Delete
.upwind/to start from empty. It is local data, and no deployment reads it.
A host can also register customer-authored Durable Object classes. The adapter builds each class into
its own small Worker and the SDK publishes the host's native namespace through
durableObject(name) or @stayingupwind/sdk/durable-object. Class code stays outside the Next.js
Functions. For local upwind dev and upwind build, provide the host's exported
{ name, module, className } registrations as UPWIND_DURABLE_OBJECTS JSON. SQLite data persists in
.upwind/; source edits restart the dev server without deleting it. No object is constructed at startup.
See the SDK guide for class and request-handler examples.
If the host supplies definitionRevision, keep that positive safe integer in each registration.
The adapter returns it unchanged for publication checks without changing the class or namespace.
Hosts can also attach a Cloudflare Container to an object's namespace, from a Dockerfile or an
existing image. The class uses ctx.container or @cloudflare/containers; Next.js reads its native
namespace through the same SDK accessor, with no added serving hop. Configuration is host-owned;
see the adapter contract and
local Docker setup.
A Container-capable host can also isolate Next.js Functions that require runtime native addons. The adapter produces Node artifacts only for their route owners, keeping independent Functions on Workers and existing edge cache delivery unchanged. This requires the host to support bundle versions 11/12; automatic image provisioning is not yet available in the standalone CLI. See native Function classification, artifacts and limits.
// upwind.config.ts, beside next.config.ts
export default {
crons: [{ path: '/api/nightly', schedule: '0 3 * * *' }],
};The adapter looks for upwind.config.ts, upwind.jsonc, upwind.json and vercel.json beside next.config, in that order, and reads only the first it finds — the files are not merged — so a vercel.json that already declares crons works as it is. Every schedule is checked against Vercel's dialect: five fields, in UTC, with no @daily. Anything it cannot run fails the build, with the file named; what it accepts is carried into the bundle for the host to run on schedule.
| Package | Version | What it is |
|---|---|---|
upwind |
The CLI: upwind dev and upwind build |
|
create-upwind |
pnpm create upwind, and the app it writes |
|
@stayingupwind/adapter |
Runs inside next build, writes the bundle, and builds the Functions that serve it |
|
@stayingupwind/runtime |
The code a deployment's Functions run — bundled into them by the adapter, never installed by hand | |
@stayingupwind/core |
The contract: the bundle's schema, the cache's terms, request classification, the edge ↔ Function protocol | |
@stayingupwind/sdk |
What an app reads its own D1, KV and R2 through, with no configuration | |
@stayingupwind/auth |
Better Auth with nothing to configure — including the OAuth provider, until you have one |
An app depends on two of them — upwind and @stayingupwind/adapter — plus the SDK once it reads storage, and the auth wrapper once it signs anybody in. All of them share one version, and create-upwind asks for its own version of both (^x.y.z), so what scaffolds a project and what runs it start out as the same generation.
The published release supports Next.js 16.2 and later 16.x (the Next.js badge at the top reads this range from npm). 16.2 is where Next.js's Adapter API became stable; earlier versions only have an experimental hook of a different shape, and supporting them would take a different adapter.
Where the Adapter API is not enough, the adapter patches Next.js's own output. The patches live under packages/adapter/src/patches/, and each one is checked against Next.js itself rather than trusted by version number:
| Check | Runs | What it answers |
|---|---|---|
pnpm check:patches |
on every pull request | the patches to Next.js's published package still find what they look for, in the Next.js this repository builds against |
pnpm check:patches:range |
daily | the same, for every release in the supported range |
pnpm check:matrix |
daily, with the range's oldest and newest release | a real next build of the fixtures — the only check that reaches the patches aimed at what the build writes |
pnpm check:patches:canary |
daily, and allowed to fail | the canary, as a forecast of what the next Next.js is about to change; the daily matrix builds it too |
Can I still run next dev and next build?
Yes — it is your Next.js. With adapterPath set in next.config, a plain next build writes the same bundle as upwind build, but for the runtime cache upwind build names through the environment (see the cache), and unless a page reads storage while it prerenders (see Storage). A plain next dev serves your app as it always has, just without the front door, so there is no /__upwind.
Why is there no start script?
The default Cloudflare deployment serves Functions behind an edge and needs no start script.
After pnpm build, pnpm preview (upwind preview) runs that bundle locally.
For a native Node deployment, build with upwind build --target=node and add "start": "upwind start".
Who can read /__upwind?
It is a dev server's front door, and it keeps four rules:
GETandHEADonly.- Never an
access-control-allow-originheader, so another origin can send it a request but cannot read the response. cache-control: no-store.- It answers only when every host name a request carries is an IP address,
localhostor a*.localhostname, or the one you passed to--hostname. So a page on a DNS-rebinding domain cannot pass as same-origin and read where your project is on disk.
Like next dev, it listens on every network interface unless told otherwise, so anyone who can reach the port can read it — projectDir included. -H localhost keeps it to this machine.
Why does a new project have AGENTS.md?
It is Next.js's own, word for word: the block that tells a coding agent this Next.js is newer than the one it was trained on, and to read the docs in node_modules/next/dist/docs/ before writing code. create-upwind writes it the way create-next-app does — AGENTS.md alone, as Next.js 16.4 does, where earlier releases wrote a CLAUDE.md beside it — and when a coding agent runs upwind dev, your Next.js brings it up to date. --no-agents-md leaves it out of the scaffold, though the first upwind dev an agent runs writes it anyway, as next dev would. The switch that lasts is agentRules: false in next.config, which upwind dev and next dev both obey.
Issues, questions and pull requests are all welcome — rough ones included. CONTRIBUTING.md has the layout of the repository, the style, and how a release is made.
| If you have | What helps most |
|---|---|
| 5 minutes | Read what the adapter reads, and open an issue about anything wrong, missing, or true of your app and not of the document |
| An afternoon | Send a small pull request: a clearer error message, a comment that names the failure it exists for, a doc fix |
| Ongoing | Tell us what your Next.js app does that upwind does not serve yet |
You need Node 24 — the repository names 24.22.1 — and pnpm, which Corepack fetches at the version the repository pins.
corepack enable # once per machine; Corepack ships with Node 24
git clone https://github.com/arkorlab/upwind.git
cd upwind
pnpm install
pnpm typecheck
pnpm lint
pnpm format
pnpm knip
pnpm build
pnpm check:patches
pnpm check:deploy-tests
pnpm check:agent-rulesThe last eight are exactly what CI runs, in the order it runs them. CI gives pnpm lint an 8 GB heap (NODE_OPTIONS=--max-old-space-size=8192), and you may need to as well.
Caution
Found a security issue? Email security@arkor.ai rather than opening a public issue. We acknowledge within 48 hours.
Licensed under either of the MIT license or the Apache License, Version 2.0, at your option. The NOTICE says what is included from Next.js, and under which terms.