Skip to content

Deploy Next.js to the edge

Hiraiship builds Next.js apps with vinext, Cloudflare's reimplementation of the Next.js API on Vite. An app deploys in one of two ways:

  • Rendered on the server, when it's built with Cloudflare's Vite plugin: Server Components and route handlers run in a Worker on Cloudflare's network, next to your static files. See Server rendering.
  • As a static export (output: "export"): every page is rendered at build time and served as static files. See Static export.

The App Router and the Pages Router both work.

Moving to vinext

A Next.js project without vinext fails to build with an error that says what to do. vinext converts it in place:

Terminal
npx vinext init --platform=cloudflare --legacy-wrangler-cloudflare-init

It adds vinext, vite and Cloudflare's Vite plugin (plus the React Server Components packages for the App Router), a vite.config.ts, a wrangler.jsonc and vinext's scripts, and makes package.json an ES module. It doesn't touch next.config or your source, and keeps next installed: the project still runs with Next.js too. See vinext's documentation for what it supports.

A new app starts the same way:

Terminal
npm create vinext-app@latest my-app -- --platform=cloudflare --legacy-wrangler-cloudflare-init

Keep the Wrangler setup

Keep --legacy-wrangler-cloudflare-init. Without it, vinext sets the project up with a cloudflare.config.ts and the beta of Cloudflare's Vite plugin 2.0, whose build output is still a draft that Hiraiship doesn't read yet: the build fails with an error saying so.

Detection

A project is treated as Next.js when vinext is in dependencies or devDependencies, whether next is there too or not. It's checked before Vite, which vinext runs on.

Defaults

SettingDefault
Install commandFrom your lockfile, see Frameworks
Build commandnpx vite build
Output directorydist/client

vinext writes its client files to dist/client, not to .next/ or out/ like Next.js.

Server rendering

With Cloudflare's Vite plugin in vite.config.ts and a wrangler.jsonc, as vinext init sets them up, the build writes a Worker and your static files, and Hiraiship publishes both. The build command doesn't change, and the output directory setting is ignored, since the build says where everything is.

vite.config.ts
import { cloudflare } from '@cloudflare/vite-plugin';
import { defineConfig } from 'vite';
import vinext from 'vinext';
 
export default defineConfig({
  plugins: [vinext(), cloudflare({ viteEnvironment: { name: 'rsc', childEnvironments: ['ssr'] } })],
});

Every path runs the Worker, except the hashed files under /_next/static/, served directly. Files in public/ are served by the Worker from your static files, and an unknown path renders your not-found page with a 404 status.

A server build without the plugin fails with an error naming the command to run.

Reading variables

Your runtime variables reach the Worker as bindings. Read them from cloudflare:workers in a Server Component or a route handler:

app/status/page.tsx
import { env } from 'cloudflare:workers';
 
export const dynamic = 'force-dynamic';
 
export default function Status() {
  return <p>{env.API_TOKEN ? 'configured' : 'missing'}</p>;
}
app/api/status/route.ts
import { env } from 'cloudflare:workers';
 
export function GET() {
  return Response.json({ configured: Boolean(env.API_TOKEN) });
}

On a project whose latest deployment rendered on the server, a new variable in the dashboard is both a build and a runtime variable by default. A secret is runtime only, so it never ends up in your bundle.

vars in wrangler.jsonc are ignored, and the build log says so: set those values as the project's variables instead. .dev.vars isn't read either.

Images

vinext's image optimizer needs Cloudflare Images, which Hiraiship doesn't provide. The IMAGES binding vinext init adds is ignored, and the build log says so: next/image still works, and /_next/image redirects to your original file, unoptimized.

What isn't supported

Hiraiship provides your Worker with its variables and its static files, nothing else. A build whose wrangler.jsonc declares other bindings fails, naming each one: KV namespaces, D1 databases, R2 buckets, Durable Objects, queues, service bindings, Workers AI, Hyperdrive, Analytics Engine, cron triggers.

That includes vinext's caches, which each need one of those: its data cache (--data-cache=kv, a KV namespace), the Workers Response Store and the Workers Cache. Pick none of them in vinext init.

The _headers file vinext writes (long caching for /_next/static/) isn't applied, and the build log says so. WebAssembly, text and data modules aren't supported either: the Worker must be JavaScript only.

CPU time

Each server-rendered request runs your Worker, within your plan's CPU time per request:

FreeStandardProPlatformEnterprise
CPU per request15 ms30 ms50 ms50 ms50 ms

That's enough for a typical page. A heavy render that goes past it fails with Cloudflare's exceededResources error. See Limits.

Static export

To deploy without a server, set output: "export" in your config:

next.config.mjs
/** @type {import('vinext').NextConfig} */
export default {
  output: 'export',
};

Without Cloudflare's plugin, the build then writes static files only, to dist/client. With the plugin, it's still published as a Worker that serves them, as wrangler deploy would.

A static export works when every page can be decided at build time or in the browser: Server and Client Components, metadata, generateStaticParams with every parameter listed, and getStaticProps/getStaticPaths (with fallback: false) in the Pages Router. What needs a server at request time doesn't: getServerSideProps, Server Actions, middleware, cookies and request headers, revalidation, on-demand image optimization.

Without output: "export" and without the plugin, vinext builds an app for a server Hiraiship doesn't run, and the build fails with an error rather than publishing pages that would never render.

Environment variables

These are the build variables every Next.js app has. A server-rendered app also reads runtime variables, see Reading variables.

process.env.NEXT_PUBLIC_* values are inlined into your pages and bundle at build time. Add them as build variables: in the dashboard, a new variable on a statically exported Next.js project is a build variable by default.

app/greeting.tsx
'use client';
 
export function Greeting() {
  return <p>{process.env.NEXT_PUBLIC_GREETING}</p>;
}

Variables without the NEXT_PUBLIC_ prefix stay out of the browser bundle, but your Server Components can read them while the site builds, and what they render is written into the exported HTML. Every visitor downloads those pages, which is why a secret can't be a build variable.

For one deployment only, pass them to the CLI:

Terminal
hiraiship deploy --build-env NEXT_PUBLIC_API_URL=https://staging-api.example.com

404 page

A static export always has a 404.html, from your not-found page (App Router) or 404 page (Pages Router), or vinext's default one. It's served with a 404 status for any path with no page.