Skip to content
arkorlabPublic

About

Cloudflare Perfect self-host for Next.js

Resources

Contributing

Stars

4 stars

Watchers

0 watching

Forks

Repository files navigation

upwind

upwind

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.

upwind on npm Next.js Node.js License: MIT OR Apache-2.0
CI Next.js matrix npm provenance Status: before 1.0 PRs welcome

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.

✨ What is upwind?

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.

🔨 upwind build

Your own next build, with upwind's adapter plugged in through Next.js's Adapter API. It writes one directory: a bundle.json, every prerender and static file as a blob named by its hash, and the Functions that serve your app — app, plus middleware when the project has a proxy.ts or middleware.ts.

🚪 upwind dev

Your own Next.js dev server — the same Turbopack, HMR and error overlay — behind that front door, which answers /__upwind the way a deployment's edge answers the platform's own paths.

🌱 pnpm create upwind

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
Loading

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.

🎯 Who it's for

  • 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 the next build you 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 deploy deploys 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.

🚀 Quick start

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 1127ms

Open 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 bundle

And 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 nothing

Tip

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-app

Dependencies 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.

Add upwind to an existing app

Your app needs Next.js in the supported range, built with Turbopack.

  1. Install the CLI and the adapter:

    pnpm add -D upwind @stayingupwind/adapter
  2. Point the dev and build scripts at upwind:

    {
      "scripts": {
        "dev": "upwind dev",
        "build": "upwind build"
      }
    }
  3. Recommended: set adapterPath in next.config.ts too, so that a plain next 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, set adapterPath only when the VERCEL environment variable is unset: an adapter named in next.config takes 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.

Run on native Node.js

pnpm upwind build --target=node
pnpm upwind start --port 3000

This 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.

💡 Why upwind

🧩 It is still your Next.js. No fork and no wrapper API: the adapter plugs into Next.js's stable Adapter API, and upwind dev runs your project's own Next.js in the same process as its front door. Nothing in your pages has to import upwind.

🔒 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. proxy.ts is also built as a small Function of its own, so an edge can run it first: a request it redirects, answers, or rewrites to a file in storage need never start the application.

🧮 WebAssembly, compiled once. A .wasm ships as a module compiled at upload, so each new application instance can use it without recompiling it on its first request.

🛑 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 vm call left in the bundle, a load the bundler could not follow — and the build fails and says why before an upload would.

🔭 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. /__upwind is answered by the front door, not by your application, so it responds while the app compiles — and while its compile fails.

🔏 Releases you can verify. Every release is published by CI from a signed tag on main, and every package carries an npm provenance attestation.

💾 Storage with nothing to configure
A D1 database, a KV namespace and an R2 bucket, run locally on the runtime a deployment uses and read with import db from '@stayingupwind/sdk/db'. More below.

🤖 Ready for coding agents
New projects get Next.js's own AGENTS.md, and upwind dev keeps it current the way next dev does. More in the FAQ.

🆚 upwind and Next.js

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
Loading

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.

🧰 What it serves

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.

💾 Storage

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, db is that database; with several it throws and lists them, and d1('ORDERS') picks the one you mean.
  • The SDK includes the required storage types — D1Database, KVNamespace, R2Bucket, imported from @cloudflare/workers-types — so no tsconfig of yours needs to know about them.
  • Prerendering can read it. In a project with the SDK installed, upwind build publishes the same storage while pages prerender, so generateStaticParams can 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 sets experimental.cpus itself, in which case a page that reads storage fails in every worker but one. A plain next build publishes 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.

Durable Objects

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.

⏰ Scheduled work

// 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.

📦 Packages

Package Version What it is
upwind upwind The CLI: upwind dev and upwind build
create-upwind create-upwind pnpm create upwind, and the app it writes
@stayingupwind/adapter @stayingupwind/adapter Runs inside next build, writes the bundle, and builds the Functions that serve it
@stayingupwind/runtime @stayingupwind/runtime The code a deployment's Functions run — bundled into them by the adapter, never installed by hand
@stayingupwind/core @stayingupwind/core The contract: the bundle's schema, the cache's terms, request classification, the edge ↔ Function protocol
@stayingupwind/sdk @stayingupwind/sdk What an app reads its own D1, KV and R2 through, with no configuration
@stayingupwind/auth @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.

📐 Which Next.js

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

❓ FAQ

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:

  • GET and HEAD only.
  • Never an access-control-allow-origin header, 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, localhost or a *.localhost name, 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.

🤝 Contributing

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-rules

The 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.

Contributors to upwind

📄 License

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.


Made by Arkor and contributors · Back to top ↑

About

Cloudflare Perfect self-host for Next.js

Resources

Contributing

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages