# 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](serving.md).

## How a deploy builds your app

`flatstack deploy` works out the framework from `package.json`, or from `framework` in the [`hosting` block](serving.md#hosting-settings) 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](serving.md#limits-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](https://opennext.js.org/cloudflare). 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.

```ts
// 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:

```tsx
// 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`:

```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](data.md) 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:

```ts
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](data.md) and [file storage](storage.md).
- Anything else [OpenNext doesn't support](https://opennext.js.org/cloudflare#supported-nextjs-features).

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