# Edge mode execution with the Next.js App Router SDK

> Show flicker-free personalizations and A/B tests from one cached page per route. Edge mode (NESI) chooses the variants of each visitor before the browser gets the page.

Source: https://docs.uniform.app/docs/sdk/nextjs-app-router/edge-mode

> **Developer Preview:**
>
> Edge mode is new in the developer preview of the SDK, version `20.81.1-alpha.25.sha-f076f9b857`. The APIs on this page can change before the stable release.

A Uniform page can contain personalizations and A/B tests, so different visitors can see different variants. Next.js serves pages fastest from the cache, but a cache entry is the same HTML for all visitors. Edge mode keeps one cached page for each route. The edge chooses the personalization and A/B test variants of the visitor before the browser gets the page. Thus the first paint is correct, and the page does not flicker.

## Lite mode and edge mode

In the developer preview, `uniformMiddleware` makes no API calls and does not choose variants. All visitors of a published route share one cached page. The SDK chooses the variants in one of two modes.

### Lite mode: the browser chooses

Use `uniformMiddleware` from `@uniformdev/next-app-router/middleware`.

The server renders the default variant of each placement:

- **Personalization:** the variants that an anonymous visitor gets.
- **A/B test:** the winner variant, else the control variant, else the first variant.

After hydration, the browser Context chooses the variants of the visitor. If they are not the defaults, the visitor sees the default first, and then the change. This change is the "flicker".

Lite mode is the least expensive setup, and it uses no more packages.

### Edge mode: the edge chooses

Use `vercelUniformEdgeMiddleware` from `@uniformdev/next-app-router/vercel`, or `uniformEdgeMiddleware` from `@uniformdev/next-app-router/edge`.

The cached page contains all variants. The edge middleware keeps only the variants of the visitor as the HTML goes to the browser. The visitor sees the correct variant on the first paint, with no flicker.

## How edge mode works

```mermaid
sequenceDiagram
  participant B as Browser
  participant M as Edge middleware
  participant O as Next.js (cache)
  B->>M: GET /about
  Note over M: Make the code.<br/>Make the visitor Context from<br/>cookies, URL and geo headers.
  M->>O: GET /uniform/[code]<br/>x-uniform-edge-origin: 1
  O-->>M: Cached HTML with all variants<br/>between NESI tags
  Note over M: Keep the visitor's variants.<br/>Remove the other variants.<br/>Write the visitor state.
  M-->>B: HTML, Cache-Control: private
  Note over B: EdgePlacement hydrates<br/>the kept variants.
```

1. **The middleware puts the page in edge mode.** The [edge route filter](#choose-the-pages-for-the-edge) selects published pages. For these pages, the middleware sets the `edgeMode` flag in the code. Draft and editor requests never use edge mode.
2. **The page renders all variants.** With the `edgeMode` flag, the SDK renders each `$personalization` and `$test` component as an `EdgePlacement`. `EdgePlacement` puts each variant between NESI tags. Next.js caches this page one time for each route.
3. **The middleware fetches the cached page.** For an HTML document request, the middleware fetches the rewritten URL with the `x-uniform-edge-origin: 1` header. It makes the Context of the visitor from the cookies, the URL and the geolocation quirks at the same time.
4. **The middleware keeps the variants of the visitor.** The middleware streams the HTML through a transform. The transform keeps the variants of the visitor and removes the other variants. When the response has no ETag, it also writes the state of the visitor into the `__UNIFORM_DATA__` script for the browser Context.
5. **The browser hydrates the kept variants.** `EdgePlacement` finds the variants that the edge kept, and hydrates them. It also sends the personalization and test events to the browser Context for analytics.

`UniformContext` adds the `__UNIFORM_DATA__` script on pages in edge mode. You do not add it to your layout.

### Which requests the edge processes

The middleware fetches and transforms only a GET request for an HTML document of a published page. These requests only get a rewrite:

- **Client-side navigations and prefetches.** Next.js sends them with the `rsc` or `next-router-prefetch` header. The browser sends them with `Sec-Fetch-Dest: empty`.
- **Draft and editor requests.** The browser chooses the variants.
- **Pages that the [edge route filter](#choose-the-pages-for-the-edge) skips.** These pages stay cacheable by the CDN, and the browser chooses their variants.
- **Requests that are not GET requests.**

The middleware finds a document request from the `Sec-Fetch-Dest` header: `document`, or `iframe` for the Canvas preview. Some clients, for example crawlers, do not send this header. For these clients, the `Accept` header must be missing, or must include `text/html` or `*/*`.

### Client-side navigations

A client-side navigation does not go through the transform. The RSC payload contains all variants, so `EdgePlacement` chooses the variants with the browser Context before the new page paints. Thus client-side navigations also show no flicker.

When the scores or quirks of the visitor change, `EdgePlacement` chooses the personalization variants again. Test variants do not change.

## Set up edge mode on Vercel

1. Install the Vercel functions package:

   ```bash
   npm install @vercel/functions
   ```
2. Replace the middleware:

   `middleware.ts`

   ```ts
   import { vercelUniformEdgeMiddleware } from "@uniformdev/next-app-router/vercel";

   export default vercelUniformEdgeMiddleware({
     // the same options as uniformMiddleware, for example rewriteRequestPath
   });

   export const config = {
     matcher: [
       {
         source: "/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
         missing: [{ type: "header", key: "x-uniform-edge-origin" }],
       },
     ],
     runtime: "experimental-edge",
   };
   ```

   The middleware fetches the cached page with the `x-uniform-edge-origin` header. The `missing` rule stops a second run of the middleware on that fetch.
3. Expire the Vercel runtime cache when content is published:

   `app/api/preview/route.ts`

   ```ts
   import {
     createPreviewGETRouteHandler,
     createPreviewOPTIONSRouteHandler,
     createPreviewPOSTRouteHandler,
   } from "@uniformdev/next-app-router/handler";
   import { expireVercelRuntimeCacheTags } from "@uniformdev/next-app-router/vercel";

   export const GET = createPreviewGETRouteHandler();
   export const POST = createPreviewPOSTRouteHandler({ onRevalidateTags: expireVercelRuntimeCacheTags });
   export const OPTIONS = createPreviewOPTIONSRouteHandler();
   ```

   Add a Uniform webhook for `/api/preview`. Refer to [Caching](https://docs.uniform.app/docs/sdk/nextjs-app-router/caching#webhooks).
4. If you prebuild pages, prebuild the two edge mode values:

   `app/uniform/[code]/page.tsx`

   ```tsx
   export const generateStaticParams = () =>
     createUniformStaticParams({ paths: ["/"], edgeMode: [true, false] });
   ```

   The default filter skips pages that it learns have no placements. These pages get a code without the edge mode flag.

`vercelUniformEdgeMiddleware` uses these defaults:

| Option | Default | Effect |
| --- | --- | --- |
| `manifest` | `vercelManifestProvider()` | Gets the published manifest at runtime, and keeps it in the Vercel runtime cache with the `manifest` tag. |
| `filter` | `learnedEdgeRouteFilter` | Skips the pages that the store recorded without placements. |
| `store` | `vercelEdgeRouteStore()` | Records what the transform learns about each page in the Vercel runtime cache, with the `path:` tag of the page. |
| `etags` | `true` | Gives per-visitor ETags to processed pages. |

Set `filter: false` to process all published pages. Set `store: false` to record nothing. Then the edge processes all published pages, and responses get no ETag.

> **Note:**
>
> Under `next dev`, the Vercel helpers do not use the runtime cache, unless `RUNTIME_CACHE_ENDPOINT` is set. The manifest provider then gets the manifest from the API and keeps a copy in memory. The store records in memory for each instance, and the records do not expire. `expireVercelRuntimeCacheTags` does nothing. Restart `next dev` after you add a placement to a page that the store recorded without placements.

## Set up edge mode on other hosts

`uniformEdgeMiddleware` from `@uniformdev/next-app-router/edge` works without Vercel. You must give it the manifest:

`middleware.ts`

```ts
import type { ManifestV2 } from "@uniformdev/context";
import { uniformEdgeMiddleware } from "@uniformdev/next-app-router/edge";

import manifest from "./lib/uniform/contextManifest.json";

export default uniformEdgeMiddleware({ manifest: manifest as ManifestV2 });

export const config = {
  matcher: [
    {
      source: "/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
      missing: [{ type: "header", key: "x-uniform-edge-origin" }],
    },
  ],
  runtime: "experimental-edge",
};
```

Download the manifest before each build:

`package.json`

```json
{
  "scripts": {
    "build": "npm run uniform:manifest && next build",
    "uniform:manifest": "uniform context manifest download --output lib/uniform/contextManifest.json"
  }
}
```

With a manifest in the build, the middleware makes no network calls of its own. It only fetches the cached page.

> **Warning:**
>
> A manifest in the build becomes old when you publish new signals or tests. The edge uses them after the next build. Until then, the edge shows the defaults for new personalizations, and the browser chooses the variants of new tests.

### uniformEdgeMiddleware options

`uniformEdgeMiddleware` accepts all [middleware options](https://docs.uniform.app/docs/sdk/nextjs-app-router/middleware#middleware-options), and these options:

| Option | Default | Description |
| --- | --- | --- |
| `manifest` | Required | The Context manifest (`ManifestV2`), or a provider function that returns it. |
| `filter` | Process all published pages | An `EdgeRouteFilter` that selects the pages to process. |
| `store` | Record nothing | An `EdgeRouteStore` that records what the transform learns about each page. |
| `etags` | `true` | Gives per-visitor ETags to processed pages. Works only with a `store`. |
| `onError` | Log transform errors to the console. Manifest errors are not logged. | Called when the manifest provider or the transform fails. |

### handleUniformEdgeRoute

`handleUniformEdgeRoute` handles one request, so you can add your own logic around it. Give it `waitUntil`, so the middleware can finish cache writes after the response:

`middleware.ts`

```ts
import type { ManifestV2 } from "@uniformdev/context";
import { handleUniformEdgeRoute } from "@uniformdev/next-app-router/edge";
import type { NextFetchEvent, NextRequest } from "next/server";

import manifest from "./lib/uniform/contextManifest.json";

export default function middleware(request: NextRequest, event: NextFetchEvent) {
  return handleUniformEdgeRoute({
    request,
    waitUntil: (promise) => event.waitUntil(promise),
    manifest: manifest as ManifestV2,
  });
}
```

### Load the manifest at runtime on other hosts

`createCachedManifestProvider` gets the published manifest at runtime. Give it a `cache` with `get` and `set` functions for your platform:

```ts
import { createCachedManifestProvider, uniformEdgeMiddleware } from "@uniformdev/next-app-router/edge";

export default uniformEdgeMiddleware({
  manifest: createCachedManifestProvider({
    cache: myPlatformCache, // { get(key), set(key, value, { ttl, tags }) }
  }),
});
```

| Option | Default | Description |
| --- | --- | --- |
| `cache` | none | A shared cache. Without it, each instance keeps the manifest in memory, and gets it again from the Uniform API after `maxAge`. |
| `maxAge` | `10` | The time in seconds that an instance uses its copy in memory before it reads the shared cache again. |
| `ttl` | `false` | The time in seconds that the shared cache keeps the manifest. `false` keeps it until the `manifest` tag expires. |
| `fallback` | none | A manifest to use when the first load fails. |

The provider writes the manifest with the tag `manifest`. When a load fails, it uses the last manifest that it loaded, then the `fallback`. If neither exists, the middleware calls `onError` and uses an empty manifest. Then personalizations show their defaults, and the browser chooses the test variants.

## Choose the pages for the edge

An edge route filter decides which published pages the middleware processes. The middleware only rewrites a page that the filter skips. Such a page stays cacheable by the CDN, and the browser chooses its variants.

| Filter | Behavior |
| --- | --- |
| No filter (`uniformEdgeMiddleware` default) | Process all published pages. |
| `learnedEdgeRouteFilter` (`vercelUniformEdgeMiddleware` default) | Process a page until the store records that the page has no placements. Then skip it. |
| `createStaticEdgeRouteFilter({ paths })` | Process only the listed paths. `:name` matches one path segment. |
| Your filter | An object with a `shouldProcess` function. |

```ts
import { createStaticEdgeRouteFilter } from "@uniformdev/next-app-router/edge";
import { vercelUniformEdgeMiddleware } from "@uniformdev/next-app-router/vercel";

export default vercelUniformEdgeMiddleware({
  filter: createStaticEdgeRouteFilter({ paths: ["/", "/campaigns/:slug"] }),
});
```

A custom filter gets the page state and a function that reads the store record:

```ts
import type { EdgeRouteFilter } from "@uniformdev/next-app-router/edge";

const filter: EdgeRouteFilter = {
  shouldProcess: async ({ pageState, getRecord }) =>
    !pageState.routePath.startsWith("/docs") && ((await getRecord())?.hasPlacements ?? true),
};
```

Give the same filter to `createUniformStaticParams({ edgeMode: filter })`, so the prebuilt codes are the same as the codes from the middleware. At build time there is no store, so `learnedEdgeRouteFilter` selects all pages. Thus use `edgeMode: [true, false]` with the learned filter.

> **Warning:**
>
> The store does not know when a page gets a placement through a pattern or an entry. Example: you add a personalization to a pattern. A page that uses the pattern can stay skipped until its record expires.
>
> To process the page again, publish the composition of the page, or change its project map node. This expires the `path:` tag of the record, but only for a static node path. The webhook for a dynamic node, for example `/products/:slug`, expires only `path:/products`. The records of the pages under that node stay until the cache removes them. For these pages, set a `ttl` in `vercelEdgeRouteStore({ ttl })`, or use a filter that does not skip them.

## Per-visitor ETags

With a store and `etags: true`, the middleware gives a weak ETag to each processed page. The ETag comes from the page version and the variants of the visitor. When the browser sends the same ETag again, the middleware answers `304 Not Modified`, and does not send the page again.

| Response | `Cache-Control` | ETag |
| --- | --- | --- |
| The visitor state did not change | `private, no-cache` | Yes |
| The visit changed the visitor state, for example a new score | `private, no-store` | No |
| No store, `etags: false`, the origin status is not 200, or the store has no record for this page version or for the variants of the visitor | `private, no-store` | No |

The middleware sends the new visitor state without an ETag. A CDN can answer `304` for an ETag that it knows, and then the browser does not get the new state.

The origin page stays cached. Only the personalized response is private.

## The Context manifest

Edge mode uses the published Context manifest to choose the variants.

| Setup | Where the manifest comes from | When it updates |
| --- | --- | --- |
| `vercelUniformEdgeMiddleware` | Uniform API, kept in the Vercel runtime cache | When the `manifest.published` webhook expires the `manifest` tag. Each instance keeps its copy in memory for a maximum of `maxAge` (10 seconds) more. |
| `uniformEdgeMiddleware` with `createCachedManifestProvider` | Uniform API, kept in your cache | When your cache expires the `manifest` tag, or after `ttl` |
| `uniformEdgeMiddleware` with a JSON file | The build | After the next build |

When the manifest of the edge does not know a signal yet, the edge shows the default variants. When it does not know a test yet, the browser chooses the variant.

## Limits

- **Visibility rules run in the browser.** NESI tags do not support visibility rules. A component with visibility rules is not in the server HTML, and shows after hydration.
- **Redirects pass through.** When the page answers with a redirect, the middleware sends the redirect to the visitor without the transform.
- **Draft content is not processed.** In Canvas preview, the browser chooses the variants.

## What edge mode costs

- **One more request for each page load.** The middleware fetches the cached page from its own deployment. On a deployment, this request goes across the edge network.
- **The personalized response is not cacheable by the CDN.** It is `private`. The page that the middleware fetches stays cached.
- **The RSC payload contains all variants.** The browser uses them to choose on client-side navigations. The edge removes the other variants from the HTML, but not from the inline RSC payload.
- **CPU time for the transform.** The middleware decodes and encodes the HTML of each processed page.
- **The manifest is not always current.** With a manifest in the build, new signals and tests reach the edge on the next build. With a provider, they reach it when the cache expires.

## Why Cache Components does not replace edge mode

[Cache Components](https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents) caches parts of a page, but each cache entry is still the same for all users of its key. The variant of a visitor depends on cookies, the URL and geolocation headers.

- **`'use cache'`** cannot read `cookies()`, `headers()` or `searchParams`. The cached output is the same for all visitors, so the browser must choose afterwards. This is lite mode.
- **Cookies or headers read at request time** make a dynamic part of the page. The server renders it for each request, and the visitor sees the `Suspense` fallback until it streams in.
- **`'use cache: private'`** can read cookies and headers, but Next.js does not keep the result on the server. A page load still renders the dynamic part on the server.

The middleware is the last place that sees the cached page and the request. Edge mode decides there, before the browser paints.
