Skip to content

Server-rendered apps: Next.js, Astro and React Router

Flatstack runs your app’s server-rendered pages in your project’s Worker, beside your functions, routes and sign-in. flatstack deploy builds the app with its framework’s Cloudflare adapter, bundles what it builds with the Flatstack runtime, and uploads its static files.

Framework Add to your app Starter
Next.js 16.4.1 and later, or 15.5.28 and later 15.x @opennextjs/cloudflare 1.20 or later npx flatstack init my-app --template next
Astro 6 with output: "server" @astrojs/cloudflare npx flatstack init my-app --template astro
React Router 8, framework mode (7 with the v8_viteEnvironmentApi future flag) @cloudflare/vite-plugin npx flatstack init my-app --template react-router

SvelteKit and Nuxt aren’t supported yet. Static sites from any framework (Vite, a Next.js static export, an Astro static build, a React Router SPA) are served as files: see How your app is served.

How a deploy builds your app

flatstack deploy works out the framework from package.json, or from framework in the hosting block of flatstack.config.ts ("next", "astro", "react-router" or "static"). Then it:

  1. Builds the app: opennextjs-cloudflare build for Next.js, your build script for Astro and React Router, with the package manager your lockfile names. The build’s output is shown as it runs. A failed build stops the deploy with deploy_failed.
  2. Bundles the Worker the build produced with your functions and routes, into the one module every deploy ships. The whole bundle must be under 10 MB compressed, or the deploy fails with bundle_too_large.
  3. Uploads the build’s static files (.open-next/assets, dist/client or build/client), only those that changed, with a year’s caching on the framework’s hashed files (/_next/static/*, /_astro/*, /assets/*) unless you set your own rule for them.

--skip-build deploys the last build as it is. A Next.js app without @opennextjs/cloudflare is refused rather than deployed as files, since its pages would be missing; for a static export, set framework: "static" and deploy with --assets-dir out.

How requests are answered

  1. Flatstack’s paths run the runtime: /fn/*, /auth/*, /storage/*, /realtime, /_fs/*, /api/data/* and /api/stripe/*. Don’t put pages or route handlers there. Other /api/* paths are yours: a route from flatstack/routes answers first, then your framework.
  2. A file from the build answers its own path, without running any code, so it never counts as a request.
  3. Every other request is rendered by your framework. It counts as a request, and is held to your plan’s CPU limit per request (50 ms on Hobby, 200 ms on Pro, 500 ms on Studio). Time spent waiting, on a database, an API or a model, doesn’t count, so a page or a stream can stay open far longer.

Responses stream as they’re written: React Server Components with Suspense, streamed route handlers and server-sent events reach the browser as each part is ready.

Your app’s env

Your pages see your project’s variables and secrets by name, as process.env.NAME and in env (from cloudflare:workers, or the env your framework passes to server code), plus:

Name What it is
ASSETS Your build’s static files.
IMAGES Cloudflare Images, which your framework uses to resize images.
FLATSTACK Calls your functions and routes in the same Worker: env.FLATSTACK.fetch("/fn/listTodos", { method: "POST", headers: { cookie } }). It doesn’t count as a second request. Pass the visitor’s cookie so functions know who’s signed in. Only Flatstack’s paths answer it.

Your database, file storage and sign-in settings aren’t in your pages’ env: read and write data through your functions, so your access rules always apply. The starters’ flatstack.ts shows the call.

Next.js

Next.js runs through OpenNext. Tested on every change, in the Next.js starter:

Feature On Flatstack
App Router pages rendered on the server Yes
Server actions Yes
Middleware (middleware.ts, Edge runtime) Yes. proxy.ts, which runs on Node.js, is experimental in OpenNext.
Route handlers, including streamed responses Yes
Streaming with React Server Components and Suspense Yes
AI SDK streaming routes Yes
ISR (revalidate) and revalidatePath / revalidateTag Yes, kept in your project’s storage
next/image Yes, resized by Cloudflare Images

Signed-in users

Sign-in is in the same Worker as your pages, at /auth/*, and the session is an HTTP-only cookie on your app’s address. Read it in server code with getServerSession from @flatstackdev/client/server: give it env.FLATSTACK and the request’s cookies, and it returns the session, or null when nobody is signed in. The call runs in the same Worker, so it isn’t a second request, and with no cookie at all it makes no call.

// app/flatstack.ts
import { getServerSession, type FlatstackFetcher } from "@flatstackdev/client/server";
import { getCloudflareContext } from "@opennextjs/cloudflare";
import { cookies } from "next/headers";

export async function getSession() {
  const { env } = await getCloudflareContext({ async: true });
  const flatstack = (env as unknown as { FLATSTACK: FlatstackFetcher }).FLATSTACK;
  return getServerSession(flatstack, (await cookies()).toString());
}

The same getSession() works in a server component, a server action and a route handler:

// app/account/page.tsx, a server component
import { redirect } from "next/navigation";
import { getSession } from "../flatstack";

export default async function Account() {
  const session = await getSession();
  if (session === null) redirect("/sign-in?next=%2Faccount");
  return <p>Signed in as {session.user.email}.</p>;
}

session.user has id, email, name, image, emailVerified and role. Use session.user.id wherever your app stored Clerk’s user ID. To read or change data as that user, call your functions with callFunction in the starter’s app/flatstack.ts: it passes the cookie on, so ctx.user and your access rules apply there too.

For the browser, @flatstackdev/react has <FlatstackProvider>, useSession() and the sign-in components, each marked "use client", so you can import them in App Router files. Wrap your layout’s children in a client component that creates the provider; the starter’s app/providers.tsx does this.

Protecting pages

To send signed-out visitors to sign in, in place of clerkMiddleware() and createRouteMatcher(), check the session in middleware.ts:

// middleware.ts
import { getServerSession, matchesPath, signInRedirect, type FlatstackFetcher } from "@flatstackdev/client/server";
import { getCloudflareContext } from "@opennextjs/cloudflare";
import { NextResponse, type NextRequest } from "next/server";

const SIGNED_IN_ONLY = ["/account/*", "/dashboard/*"];

export async function middleware(request: NextRequest) {
  if (matchesPath(request.nextUrl.pathname, SIGNED_IN_ONLY)) {
    const { env } = await getCloudflareContext({ async: true });
    const flatstack = (env as unknown as { FLATSTACK: FlatstackFetcher }).FLATSTACK;
    if ((await getServerSession(flatstack, request.headers.get("cookie"))) === null) {
      return NextResponse.redirect(signInRedirect(request.url));
    }
  }
  return NextResponse.next();
}

export const config = { matcher: ["/((?!_next/|api/|fn/|auth/|storage/).*)"] };

matchesPath takes exact paths and paths ending in /*, which also match the path itself: "/account/*" covers /account and /account/orders. signInRedirect sends them to /sign-in?next=<the page they asked for>. On your sign-in page, only follow next when it starts with a single /, so a link can’t send someone to another site; the starter’s app/sign-in/sign-in-form.tsx shows the check.

Middleware decides who sees a page. It doesn’t protect data: your functions and the data API check ctx.user and your access rules on every call, whatever a page does. Keep the check in the page as well, as above, so a change to the matcher can’t open it.

Astro and React Router work the same way: pass env.FLATSTACK and the request’s Cookie header to getServerSession in a page’s server code.

Caching and ISR

flatstack deploy writes open-next.config.ts when your app has none. It keeps OpenNext’s cache in a bucket of your project’s storage called fs-next-cache: ISR pages, the fetch cache, revalidation tags and resized images. They count towards your file storage like other files, and other projects can’t read them. If your app has its own open-next.config.ts, it needs these three lines for ISR and revalidation to keep their pages:

import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
import memoryQueue from "@opennextjs/cloudflare/overrides/queue/memory-queue";
import kvNextTagCache from "@opennextjs/cloudflare/overrides/tag-cache/kv-next-tag-cache";

with incrementalCache: r2IncrementalCache, tagCache: kvNextTagCache, queue: memoryQueue in defineCloudflareConfig. A page past its revalidate time is served as it was, then rebuilt in the same Worker after the response has gone, so the next visit gets the new one. revalidatePath and revalidateTag take effect at once, everywhere.

Pages Next.js prerendered at build time are built again on their first visit after each deploy, then served from the cache.

Images

next/image requests (/_next/image) are resized by Cloudflare Images. An image whose URL changes with its content (a static import) is resized once per size and format, then answered from fs-next-cache. Images from public/ or other sites are resized on each request. Each /_next/image request counts as a request.

What isn’t supported

  • export const runtime = "edge" on a page or route: remove it, everything already runs at the edge.
  • Packages that load WebAssembly, including next/og (ImageResponse): the deploy fails while bundling. Make the image at build time or with a function instead.
  • Vercel’s own services: Edge Config, Vercel KV, Blob and Analytics. Use your project’s database and file storage.
  • Anything else OpenNext doesn’t support.

Astro

output: "server" with @astrojs/cloudflare renders every page in your Worker; pages with export const prerender = true are built as files. Read variables and secrets from env in cloudflare:workers. Astro sessions aren’t supported yet, as they need a KV binding.

React Router

Framework mode with @cloudflare/vite-plugin and ssr: true. The plugin needs a wrangler.jsonc that names your Worker entry (main); Flatstack sets everything else itself. Loaders and actions read your variables and secrets from env in cloudflare:workers.

Running it locally

Use your framework’s own dev server (next dev, astro dev, react-router dev) while you build pages. To run the built app as it’s deployed, with your functions, sign-in, the ISR cache and image resizing, build it, then run npx flatstack dev: it serves the last build and reloads when you build again. Locally, files are kept in .flatstack/dev/, without signed URLs or file image transforms.

As Markdown: apps/frameworks.md