Dev GuideAPI Reference
Dev GuideAPI ReferenceDev CommunityOptimizely AcademySubmit a ticketLog In
Dev Guide

Astro ISR caching and Optimizely Graph webhooks

Implement Incremental Static Regeneration (ISR) with Redis caching and Optimizely Graph webhook-based cache invalidation for an Optimizely frontend running Astro on DXP.

Implement Incremental Static Regeneration (ISR) with Redis caching and Optimizely Graph webhook-based cache invalidation for an Optimizely frontend running Astro on Optimizely Digital Experience Platform (DXP).

ISR is a rendering strategy that refreshes an individual cached page after it changes, instead of rebuilding the whole site. Astro does not implement ISR natively the way Next.js does, so on DXP you assemble it from caching and invalidation. Astro renders pages on demand and stores the HTML responses in a shared Redis cache. When an author publishes content, a webhook invalidates that one cache entry and purges the content delivery network (CDN), so the page refreshes without a redeploy.

The caching and invalidation flow works as follows:

  • A visitor requests a page, and the cache provider or middleware renders it and stores the HTML in Redis.
  • Subsequent requests are served from the Redis origin cache, and from the Cloudflare CDN in front of it.
  • A content author publishes content in Optimizely CMS.
  • Optimizely Graph fires a webhook to POST /hooks/graph.
  • The webhook handler invalidates the Redis entry for that page and purges the CDN URL.
  • The next visitor triggers a fresh render, which is cached again.

The application serves pages from cache for fast response times, and content updates display within seconds of publishing. Updates apply incrementally rather than rebuilding the entire site for every content change. This improves performance, reduces build times, and makes publishing workflows more efficient.

For the equivalent implementation on Next.js, see Next.js ISR caching and Optimizely Graph webhooks.

📘

Note

A pure Astro static site generation (SSG) site cannot use ISR. ISR needs a server runtime to regenerate a page after its cache is invalidated. SSG bakes static HTML at build time, so purging the CDN for an SSG URL just re-serves the same stale HTML from the origin. Only a full rebuild and redeploy changes SSG content. SSG sites get a CDN cache purge when you deploy, not content-publish ISR. Use server-side rendering (SSR) with the standalone Node adapter if you need ISR.

Prerequisites

You need the following before you implement ISR for an Astro site on DXP:

  • An Astro SSR site – Configure output: 'server' with the standalone Node adapter, @astrojs/node. SSG output cannot use ISR.
  • Astro 5 or Astro 7 – Both are supported, and each uses a different mechanism. See Choose an implementation for your Astro version.
  • Node.js 22.12.0 or later – Odd-numbered Node.js releases, such as version 23, are not supported. Astro 5 runs on Node.js 20 or later, but DXP frontend hosting standardizes on 22.12.0 or later.
  • A frontend project on DXP – Redis, the CDN purge endpoint, and the Optimizely Graph credentials that this implementation uses are provisioned for you when you deploy. See Environment variables reference.

Choose an implementation for your Astro version

The implementation differs between the two supported Astro versions because Astro 7 introduced a native Cache Provider API that Astro 5 does not have.

FeatureAstro 7Astro 5
ISR mechanismNative Cache Provider API (cache.provider)Custom src/middleware.ts
Time to live (TTL) and route rulesrouteRules in astro.config.mjsPer-page Astro.locals.cache(maxAge, swr) call
Stale-while-revalidate (SWR) at the originYes, through context.waitUntilNo, delegated to the CDN
Node.js requirement22.12.0 or later20 or later
Webhook handler callcacheProvider.invalidate({ path })invalidate(path) from the middleware

SWR is a cache directive that lets a cache serve a stale page while it fetches a fresh copy in the background, so a visitor never waits for a re-render.

Both versions use the same Redis client, the same astro:DEPLOYMENT_ID:PATHNAME cache key scheme, and the same webhook handler. The shared Redis store and Optimizely Graph invalidation behave identically across versions.

Configure Astro 7 route caching

Astro 7 has a native Cache Provider API with pluggable cache backends and routeRules for declaring TTLs per route. Its only built-in provider is memoryCache(), an in-process cache that Astro documents as suitable for single-instance deployments. DXP runs multiple container instances, so replace memoryCache() with a Redis-backed provider. A shared Redis store makes invalidation coherent across every instance.

Declare route rules

Declare which routes are ISR-cached, and their TTLs, in astro.config.mjs.

// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import react from '@astrojs/react';
import tailwindcss from '@tailwindcss/vite';
import { redisCache } from './src/lib/redis-cache-provider.config.ts';

export default defineConfig({
  output: 'server',
  adapter: node({ mode: 'standalone' }),

  // Disable the CSRF origin check. This app has no user-facing forms, and the
  // /hooks/graph endpoint receives server-to-server webhooks (text/plain from
  // Optimizely Graph) that would otherwise be blocked as cross-site form
  // submissions. This setting disables CSRF for the whole app, so it is only
  // safe when there are no user-facing forms. Otherwise, scope it.
  security: { checkOrigin: false },

  integrations: [react()],
  vite: {
    plugins: [tailwindcss()],
  },

  cache: {
    provider: redisCache(),
  },

  // routeRules supply maxAge and swr to the provider's setHeaders, so it knows
  // the TTL. The home page and every content page are on-demand ISR. The API and
  // webhook routes have no rule, so they are never cached.
  routeRules: {
    '/': { maxAge: 600, swr: 1800 },
    '/[...slug]': { maxAge: 600, swr: 1800 },
  },
});

The preceding route rules set the following behavior:

  • maxAge: 600 – Pages are served fresh from cache for 10 minutes.
  • swr: 1800 – Pages are served stale for up to 30 additional minutes while they revalidate in the background.
📘

Note

routeRules patterns use Astro's file-based routing syntax, such as [...slug]. Astro 7 route caching does not support glob patterns, so * and ** do not work.

Keep the TTLs short. The webhook invalidates only the published page and the home page, so aggregate pages such as listings and navigation self-correct when their TTL lapses.

Add a Redis cache provider

Create a configuration wrapper at src/lib/redis-cache-provider.config.ts. Astro resolves the entrypoint path from the project root.

// src/lib/redis-cache-provider.config.ts
import type { CacheProviderConfig } from 'astro';

export function redisCache(): CacheProviderConfig {
  return {
    entrypoint: './src/lib/redis-cache-provider.ts',
    config: {},
  };
}

Create the provider itself at src/lib/redis-cache-provider.ts. This file must default-export a CacheProviderFactory, which Astro calls once at startup. It also exports the singleton cacheProvider by name, so the Optimizely Graph webhook route can call cacheProvider.invalidate({ path }) directly. Node.js module caching ensures that Astro's internal call and the webhook import reference the same instance.

// src/lib/redis-cache-provider.ts
import type { CacheProviderFactory } from 'astro';
import { createCluster } from 'redis';
import {
  EntraIdCredentialsProviderFactory,
  DEFAULT_TOKEN_MANAGER_CONFIG,
  REDIS_SCOPE_DEFAULT,
} from '@redis/entraid';
import { ManagedIdentityCredential } from '@azure/identity';

const DEPLOYMENT_ID = process.env.OPTIMIZELY_DXP_DEPLOYMENT_ID ?? 'local';
const REDIS_URL = process.env.REDIS_URL;
const AZURE_CLIENT_ID = process.env.AZURE_CLIENT_ID;

interface CachedEntry {
  body: string;
  status: number;
  headers: Record<string, string>;
  /** Unix ms timestamp when the entry was stored, for SWR age calculation. */
  storedAt: number;
}

interface LocalEntry extends CachedEntry {
  expiresAt: number;
}

function buildKey(pathname: string): string {
  // Normalize the trailing slash so /blog/post and /blog/post/ share one cache
  // key, preventing an invalidation for one from leaving the other stale.
  const normalized = pathname.replace(/\/$/, '') || '/';
  return `astro:${DEPLOYMENT_ID}:${normalized}`;
}

function parseCacheControl(cc: string): { maxAge: number; swr: number } {
  const maxAge = parseInt(cc.match(/(?:^|,)\s*max-age=(\d+)/i)?.[1] ?? '0', 10);
  const swr = parseInt(cc.match(/stale-while-revalidate=(\d+)/i)?.[1] ?? '0', 10);
  return { maxAge, swr };
}

class RedisCacheProvider {
  private client: ReturnType<typeof createCluster> | null = null;
  private connectPromise: Promise<ReturnType<typeof createCluster> | null> | null = null;
  private readonly localFallback = new Map<string, LocalEntry>();

  /**
   * Keys currently being refreshed in this process, which prevents a per-replica
   * SWR stampede. Multi-replica deployments may still issue one refresh per
   * replica. A distributed lock (such as Redis SET NX) would be needed to
   * prevent a cross-replica stampede.
   */
  private readonly refreshing = new Set<string>();

  readonly name = 'redis-isr-cache';

  setHeaders(options: { maxAge?: number; swr?: number; tags?: string[] }): Headers {
    const headers = new Headers();
    if (options.maxAge && options.maxAge > 0) {
      let value = `public, max-age=${options.maxAge}`;
      if (options.swr && options.swr > 0) {
        value += `, stale-while-revalidate=${options.swr}`;
      }
      headers.set('Cache-Control', value);
      headers.set('CDN-Cache-Control', value);
    }
    return headers;
  }

  async onRequest(
    context: { request: Request; url: URL; waitUntil?: (promise: Promise<unknown>) => void },
    next: () => Promise<Response>,
  ): Promise<Response> {
    const { pathname } = context.url;
    const key = buildKey(pathname);

    const hit = await this.readEntry(key);
    if (hit) {
      const cc = hit.headers['cache-control'] ?? '';
      const { maxAge, swr } = parseCacheControl(cc);
      if (maxAge === 0) return new Response(hit.body, { status: hit.status, headers: hit.headers });

      const ageSeconds = (Date.now() - hit.storedAt) / 1000;
      const isStale = ageSeconds > maxAge && swr > 0 && ageSeconds <= maxAge + swr;

      if (isStale && !this.refreshing.has(key)) {
        this.refreshing.add(key);
        const refresh = next()
          .then((fresh) => this.storeResponse(key, fresh))
          .finally(() => this.refreshing.delete(key))
          .catch((e) => console.warn('[redis-cache] Background refresh error:', e));

        // context.waitUntil is the Astro 7 API for edge runtimes. When it is
        // absent (Node.js), the promise runs in the event loop unscheduled.
        context.waitUntil?.(refresh);
      }

      return new Response(hit.body, {
        status: hit.status,
        headers: { ...hit.headers, 'X-Astro-Cache': isStale ? 'STALE' : 'HIT' },
      });
    }

    const response = await next();
    const stored = await this.storeResponse(key, response);
    if (!stored) {
      // Not cacheable (binary, non-200, Set-Cookie). Pass it through unchanged.
      return response;
    }

    return new Response(await response.clone().text(), {
      status: response.status,
      headers: { ...Object.fromEntries(response.headers), 'X-Astro-Cache': 'MISS' },
    });
  }

  async invalidate({ path, tags }: { path?: string; tags?: string | string[] }): Promise<void> {
    const paths = path ? [path] : [];
    const redis = await this.redis();

    if (redis) {
      try {
        const keys = paths.map(buildKey);
        if (keys.length) await redis.del(keys);
      } catch (err) {
        console.warn('[redis-cache] Invalidate error:', (err as Error).message);
      }
    } else {
      for (const p of paths) this.localFallback.delete(buildKey(p));
    }

    if (tags) {
      console.warn('[redis-cache] Tag-based invalidation not supported; skipping tags:', tags);
    }
  }

  private redis(): Promise<ReturnType<typeof createCluster> | null> {
    if (!REDIS_URL) return Promise.resolve(null);
    if (this.client) return Promise.resolve(this.client);
    if (!this.connectPromise) this.connectPromise = this.connect();
    return this.connectPromise;
  }

  private async connect(): Promise<ReturnType<typeof createCluster> | null> {
    try {
      const credential = new ManagedIdentityCredential({ clientId: AZURE_CLIENT_ID });
      const credentialsProvider =
        EntraIdCredentialsProviderFactory.createForDefaultAzureCredential({
          credential,
          scopes: REDIS_SCOPE_DEFAULT,
          tokenManagerConfig: DEFAULT_TOKEN_MANAGER_CONFIG,
        });

      const [host, portStr] = REDIS_URL!.replace(/^rediss?:\/\//, '').split(':');
      const port = portStr ? Number(portStr) : 10000;
      const net = await import('node:net');

      // Azure Managed Redis (*.redis.azure.net) is TLS-only on port 10000.
      // Honoring a plain redis:// scheme would set tls:false, and the TLS-only
      // port closes the socket immediately. Force TLS for Azure hosts.
      const isTls = REDIS_URL!.startsWith('rediss://') || /\.redis\.azure\.net$/i.test(host);

      const cluster = createCluster({
        rootNodes: [{ url: `${isTls ? 'rediss' : 'redis'}://${host}:${port}` }],
        defaults: {
          socket: { tls: isTls, connectTimeout: 10_000 },
          credentialsProvider,
        },
        // Azure Cache for Redis advertises its nodes by internal IP in CLUSTER
        // SLOTS. Those addresses are unreachable from the container, and their
        // TLS certificates do not match, so rewrite any advertised IP back to
        // the public host you dialed. Keep hostnames as they are.
        nodeAddressMap: (address) => {
          const [hostOrIp, nodePort] = address.split(':');
          return {
            host: net.isIP(hostOrIp) !== 0 ? host : hostOrIp,
            port: Number(nodePort),
          };
        },
      });

      cluster.on('error', (err) => console.warn('[redis-cache] Cluster error:', err.message));

      // The aggregate "All the root nodes are unavailable" error hides why each
      // node connection failed (DNS, TLS, auth, or timeout). Log them per node.
      cluster.on('node-error', (err: Error, node: { host: string; port: number }) =>
        console.error(`[redis-cache] node-error (${node?.host}:${node?.port}):`, err?.message));

      await cluster.connect();
      this.client = cluster;
      return cluster;
    } catch (err) {
      const e = err as Error & { cause?: unknown; errors?: unknown[] };
      console.warn('[redis-cache] Connection failed, using in-memory fallback:', e.message);
      if (e.cause) console.warn('[redis-cache]   cause:', e.cause instanceof Error ? e.cause.message : e.cause);
      this.connectPromise = null;
      return null;
    }
  }

  private async readEntry(key: string): Promise<CachedEntry | null> {
    const redis = await this.redis();
    if (redis) {
      try {
        const raw = await redis.get(key);
        return raw ? (JSON.parse(raw) as CachedEntry) : null;
      } catch (err) {
        console.warn('[redis-cache] Read error:', (err as Error).message);
        return null;
      }
    }

    const entry = this.localFallback.get(key);
    if (!entry || entry.expiresAt < Date.now()) {
      this.localFallback.delete(key);
      return null;
    }
    return entry;
  }

  /** Returns true if the response was stored, false if it was not cacheable. */
  private async storeResponse(key: string, response: Response): Promise<boolean> {
    if (
      response.status !== 200 ||
      response.headers.has('set-cookie') ||
      !response.headers.get('content-type')?.includes('text/html')
    ) {
      return false;
    }

    const body = await response.clone().text();
    const headers: Record<string, string> = {};
    response.headers.forEach((v, k) => { headers[k] = v; });

    const { maxAge, swr } = parseCacheControl(headers['cache-control'] ?? '');
    if (maxAge === 0) return false; // Route not configured for caching.

    const totalTtl = maxAge + swr;
    const entry: CachedEntry = { body, status: response.status, headers, storedAt: Date.now() };

    const redis = await this.redis();
    if (redis) {
      try {
        await redis.set(key, JSON.stringify(entry), { EX: totalTtl });
      } catch (err) {
        console.warn('[redis-cache] Write error:', (err as Error).message);
      }
    } else {
      this.localFallback.set(key, { ...entry, expiresAt: Date.now() + totalTtl * 1000 });
    }
    return true;
  }
}

// Singleton. Astro's factory call and the webhook's named import both reference
// this instance.
const _provider = new RedisCacheProvider();
const factory: CacheProviderFactory = (_config) => _provider;

export const cacheProvider = _provider;
export default factory;

The following sections explain the key design decisions in this file.

Cache provider methods

  • setHeaders – Emits Cache-Control and CDN-Cache-Control from the routeRules TTLs, which Cloudflare uses for its shared CDN cache.
  • onRequest – Checks Redis on every request and returns X-Astro-Cache: HIT when the entry is fresh. Entries inside the stale-while-revalidate window trigger a background refresh through context.waitUntil and return X-Astro-Cache: STALE without blocking the response. Cache misses render, store, and return X-Astro-Cache: MISS.
  • invalidate({ path }) – Deletes a Redis key by path. The Optimizely Graph webhook handler calls this directly.

The provider caches only 200 responses with a text/html content type and no Set-Cookie header. The Redis TTL is maxAge + swr, so entries survive into the stale window. The refreshing set prevents a per-instance stampede, because only one background refresh runs at a time for each key on each replica.

Cache key namespacing

When multiple deployment slots share the same Redis instance, cache keys are namespaced with OPTIMIZELY_DXP_DEPLOYMENT_ID to prevent collisions:

astro:DEPLOYMENT_ID:PATHNAME

This produces keys such as astro:abc123:/blog/my-post and astro:def456:/blog/my-post for two different slots, so cache invalidation in one slot does not affect another.

Connect and authenticate Redis

Redis is provisioned automatically on DXP. Authentication uses Azure managed identities through the ManagedIdentityCredential class from @azure/identity. No connection strings or passwords are stored in configuration. The provider uses the following connection settings:

  • REDIS_URL contains the hostname and port, for example rediss://myredis.redis.azure.net:10000.
  • Transport Layer Security (TLS) is always enforced for Azure Managed Redis hosts, which are on the .redis.azure.net domain.
  • Azure Cache for Redis advertises its cluster nodes by internal IP address. The provider rewrites those addresses back to the public host through nodeAddressMap. Without this, connections fail with the error "All the root nodes are unavailable."
  • When REDIS_URL is absent, such as in local development, or the connection fails, the provider falls back to an in-memory Map.

Configure the page route

Set prerender = false on each page to enable on-demand rendering. You do not need per-page TTL configuration, because routeRules in astro.config.mjs handles it centrally.

---
// src/pages/[...slug].astro
// Dynamic catch-all content page. Resolves any Optimizely CMS path to its
// content, discovers the content type and fields through introspection, and
// renders them generically.
//
// ISR: on-demand rendered (prerender = false). Cache behavior is declared in
// astro.config.mjs routeRules for '/[...slug]':
//   maxAge: 600  — fresh for 10 minutes in the CDN and Redis origin cache
//   swr:   1800  — serve stale for up to 30 minutes while revalidating

import Layout from '../layouts/Layout.astro';
import PageContent from '../components/PageContent.astro';
import { getPageByPath } from '../lib/graph-queries';

export const prerender = false;

// The catch-all slug param is the full sub-path and may contain "/".
// getPageByPath normalizes the trailing slash before matching the CMS url.default.
const { slug } = Astro.params;
const path = `/${slug ?? ''}`;

const content = await getPageByPath(path).catch((err) => {
  console.error('[[...slug]] Graph fetch error:', err);
  return null;
});

if (!content) {
  return new Response('Not Found', { status: 404 });
}

const title = content._metadata?.displayName || 'Untitled';
---

<Layout title={title}>
  <main class="mx-auto max-w-3xl px-6 py-12">
    <a href="/" class="mb-4 inline-block text-sm text-blue-600 no-underline dark:text-blue-400">
      &larr; Back to home
    </a>
    <PageContent content={content} />
  </main>
</Layout>

Configure the Astro 5 middleware cache

Astro 5 has no Cache Provider API and no routeRules, so implement the origin cache as middleware instead. The Redis internals are identical to the Astro 7 provider, so the shared Redis store and the Optimizely Graph webhook invalidation work the same way across both versions.

Add the middleware origin cache

Create src/middleware.ts.

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
import { createCluster } from 'redis';
import {
  EntraIdCredentialsProviderFactory,
  DEFAULT_TOKEN_MANAGER_CONFIG,
  REDIS_SCOPE_DEFAULT,
} from '@redis/entraid';
import { ManagedIdentityCredential } from '@azure/identity';

const DEPLOYMENT_ID = process.env.OPTIMIZELY_DXP_DEPLOYMENT_ID ?? 'local';
const REDIS_URL = process.env.REDIS_URL;
const AZURE_CLIENT_ID = process.env.AZURE_CLIENT_ID;

interface CachedEntry {
  body: string;
  status: number;
  headers: Record<string, string>;
  /** Unix ms timestamp when the entry was stored, for SWR age calculation. */
  storedAt: number;
}

interface LocalEntry extends CachedEntry {
  expiresAt: number;
}

function buildKey(pathname: string): string {
  const normalized = pathname.replace(/\/$/, '') || '/';
  return `astro:${DEPLOYMENT_ID}:${normalized}`;
}

function parseCacheControl(cc: string): { maxAge: number; swr: number } {
  const maxAge = parseInt(cc.match(/(?:^|,)\s*max-age=(\d+)/i)?.[1] ?? '0', 10);
  const swr = parseInt(cc.match(/stale-while-revalidate=(\d+)/i)?.[1] ?? '0', 10);
  return { maxAge, swr };
}

function cacheControlValue(maxAge: number, swr: number): string {
  let value = `public, max-age=${maxAge}`;
  if (swr > 0) value += `, stale-while-revalidate=${swr}`;
  return value;
}

/**
 * Redis-backed origin cache. A single module-level instance, because Node.js
 * module caching means the middleware and the routes that import `invalidate`
 * share one store.
 */
class RedisOriginCache {
  private client: ReturnType<typeof createCluster> | null = null;
  private connectPromise: Promise<ReturnType<typeof createCluster> | null> | null = null;
  private readonly localFallback = new Map<string, LocalEntry>();

  async invalidate(path: string): Promise<void> {
    const key = buildKey(path);
    const redis = await this.redis();
    if (redis) {
      try {
        await redis.del(key);
      } catch (err) {
        console.warn('[isr-cache] Invalidate error:', (err as Error).message);
      }
    } else {
      this.localFallback.delete(key);
    }
  }

  async readEntry(key: string): Promise<CachedEntry | null> {
    const redis = await this.redis();
    if (redis) {
      try {
        const raw = await redis.get(key);
        return raw ? (JSON.parse(raw) as CachedEntry) : null;
      } catch (err) {
        console.warn('[isr-cache] Read error:', (err as Error).message);
        return null;
      }
    }

    const entry = this.localFallback.get(key);
    if (!entry || entry.expiresAt < Date.now()) {
      this.localFallback.delete(key);
      return null;
    }
    return entry;
  }

  /** Returns true if the response was stored, false if it was not cacheable. */
  async storeResponse(key: string, response: Response): Promise<boolean> {
    if (
      response.status !== 200 ||
      response.headers.has('set-cookie') ||
      !response.headers.get('content-type')?.includes('text/html')
    ) {
      return false;
    }

    const body = await response.clone().text();
    const headers: Record<string, string> = {};
    response.headers.forEach((v, k) => { headers[k] = v; });

    const { maxAge, swr } = parseCacheControl(headers['cache-control'] ?? '');
    if (maxAge === 0) return false; // Route not configured for caching.

    const totalTtl = maxAge + swr;
    const entry: CachedEntry = { body, status: response.status, headers, storedAt: Date.now() };

    const redis = await this.redis();
    if (redis) {
      try {
        await redis.set(key, JSON.stringify(entry), { EX: totalTtl });
      } catch (err) {
        console.warn('[isr-cache] Write error:', (err as Error).message);
      }
    } else {
      this.localFallback.set(key, { ...entry, expiresAt: Date.now() + totalTtl * 1000 });
    }
    return true;
  }

  private redis(): Promise<ReturnType<typeof createCluster> | null> {
    if (!REDIS_URL) return Promise.resolve(null);
    if (this.client) return Promise.resolve(this.client);
    if (!this.connectPromise) this.connectPromise = this.connect();
    return this.connectPromise;
  }

  private async connect(): Promise<ReturnType<typeof createCluster> | null> {
    try {
      const credential = new ManagedIdentityCredential({ clientId: AZURE_CLIENT_ID });
      const credentialsProvider =
        EntraIdCredentialsProviderFactory.createForDefaultAzureCredential({
          credential,
          scopes: REDIS_SCOPE_DEFAULT,
          tokenManagerConfig: DEFAULT_TOKEN_MANAGER_CONFIG,
        });

      const [host, portStr] = REDIS_URL!.replace(/^rediss?:\/\//, '').split(':');
      const port = portStr ? Number(portStr) : 10000;
      const net = await import('node:net');

      // Azure Managed Redis is TLS-only on port 10000, so force TLS for Azure hosts.
      const isTls = REDIS_URL!.startsWith('rediss://') || /\.redis\.azure\.net$/i.test(host);

      const cluster = createCluster({
        rootNodes: [{ url: `${isTls ? 'rediss' : 'redis'}://${host}:${port}` }],
        defaults: {
          socket: { tls: isTls, connectTimeout: 10_000 },
          credentialsProvider,
        },
        // Rewrite advertised internal IPs back to the public host you dialed.
        nodeAddressMap: (address) => {
          const [hostOrIp, nodePort] = address.split(':');
          return {
            host: net.isIP(hostOrIp) !== 0 ? host : hostOrIp,
            port: Number(nodePort),
          };
        },
      });

      cluster.on('error', (err) => console.warn('[isr-cache] Cluster error:', err.message));
      cluster.on('node-error', (err: Error, node: { host: string; port: number }) =>
        console.error(`[isr-cache] node-error (${node?.host}:${node?.port}):`, err?.message));

      await cluster.connect();
      this.client = cluster;
      return cluster;
    } catch (err) {
      const e = err as Error & { cause?: unknown };
      console.warn('[isr-cache] Connection failed, using in-memory fallback:', e.message);
      if (e.cause) console.warn('[isr-cache]   cause:', e.cause instanceof Error ? e.cause.message : e.cause);
      this.connectPromise = null;
      return null;
    }
  }
}

const cache = new RedisOriginCache();

/** Delete a shared cache entry for a request path. Used by the Graph webhook. */
export function invalidate(path: string): Promise<void> {
  return cache.invalidate(path);
}

export const onRequest = defineMiddleware(async (context, next) => {
  const key = buildKey(context.url.pathname);

  // Serve from cache if present. HIT while fresh, STALE within the SWR window.
  const hit = await cache.readEntry(key);
  if (hit) {
    const { maxAge, swr } = parseCacheControl(hit.headers['cache-control'] ?? '');
    const ageSeconds = (Date.now() - hit.storedAt) / 1000;
    const isStale = maxAge > 0 && ageSeconds > maxAge && swr > 0 && ageSeconds <= maxAge + swr;

    return new Response(hit.body, {
      status: hit.status,
      headers: { ...hit.headers, 'X-Astro-Cache': isStale ? 'STALE' : 'HIT' },
    });
  }

  // Miss. Let the page opt into caching through Astro.locals.cache(), then render.
  let ttl: { maxAge: number; swr: number } | null = null;
  context.locals.cache = (maxAge: number, swr = 0) => {
    if (maxAge > 0) ttl = { maxAge, swr };
  };

  const response = await next();

  // If the page opted in, stamp Cache-Control so storeResponse can read the TTL
  // and the CDN gets its directives, store in Redis, and tag the response.
  if (ttl) {
    const value = cacheControlValue(ttl.maxAge, ttl.swr);
    response.headers.set('Cache-Control', value);
    response.headers.set('CDN-Cache-Control', value);

    const stored = await cache.storeResponse(key, response);
    if (stored) {
      return new Response(await response.clone().text(), {
        status: response.status,
        headers: { ...Object.fromEntries(response.headers), 'X-Astro-Cache': 'MISS' },
      });
    }
  }

  return response;
});

The middleware caches only 200 responses with a text/html content type and no Set-Cookie header, so API routes, webhooks, and authenticated responses pass through untouched. The Redis TTL is maxAge + swr, so entries survive into the stale window.

📘

Note

On Astro 5, stale-while-revalidate is delegated to the CDN. The stale-while-revalidate directive in Cache-Control and CDN-Cache-Control tells Cloudflare to serve stale content while it revalidates, but there is no background regeneration at the origin, because Astro 5 has no context.waitUntil. A hard origin miss renders fresh.

Opt a page into the cache

Pages opt into the middleware cache by calling Astro.locals.cache(maxAge, swr) in their frontmatter. This is the Astro 5 equivalent of Astro 7 routeRules.

---
// src/pages/[...slug].astro
// ISR: on-demand rendered (prerender = false) and opted into the middleware
// origin cache, so the Graph webhook can regenerate it on publish.
//   maxAge: 600  — fresh for 10 minutes in the CDN and Redis origin cache
//   swr:   1800  — the CDN serves stale for up to 30 minutes while revalidating

import Layout from '../layouts/Layout.astro';
import PageContent from '../components/PageContent.astro';
import { getPageByPath } from '../lib/graph-queries';

export const prerender = false;

// Opt this route into the middleware origin cache.
Astro.locals.cache?.(600, 1800);

const { slug } = Astro.params;
const path = `/${slug ?? ''}`;

const content = await getPageByPath(path).catch((err) => {
  console.error('[[...slug]] Graph fetch error:', err);
  return null;
});

if (!content) {
  return new Response('Not Found', { status: 404 });
}

const title = content._metadata?.displayName || 'Untitled';
---

Declare the helper on App.Locals in env.d.ts so that it type-checks.

// env.d.ts
/// <reference path=".astro/types.d.ts" />
/// <reference types="astro/client" />

declare namespace App {
  interface Locals {
    /**
     * Opt the current route into the middleware origin cache.
     * Call this in page frontmatter, such as Astro.locals.cache(3600, 86400).
     */
    cache?: (maxAge: number, swr?: number) => void;
  }
}

Routes that do not call Astro.locals.cache(), such as API endpoints and webhooks, pass through the middleware uncached.

Configure Astro 5

Astro 5 needs no cache configuration in astro.config.mjs, because the origin cache lives in the middleware.

// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import react from '@astrojs/react';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  output: 'server',
  adapter: node({ mode: 'standalone' }),

  // Disable the CSRF origin check. This app has no user-facing forms, and the
  // /hooks/graph endpoint receives server-to-server webhooks (text/plain from
  // Optimizely Graph) that would otherwise be blocked as cross-site form
  // submissions. This setting disables CSRF for the whole app, so it is only
  // safe when there are no user-facing forms. Otherwise, scope it.
  security: { checkOrigin: false },

  integrations: [react()],
  vite: {
    plugins: [tailwindcss()],
  },
});

Optimizely Graph webhooks

Optimizely Graph fires webhooks when published content is synced. These webhooks trigger on-demand cache invalidation, so content updates display on the site within seconds of publishing.

The webhook integration is identical for Astro 7 and Astro 5. The only difference is which invalidation function the handler calls.

Register webhooks

Webhooks are registered through the Optimizely Graph API at the /api/webhooks endpoint of the gateway URL, authenticated with Basic auth using OPTIMIZELY_GRAPH_APP_KEY and OPTIMIZELY_GRAPH_SECRET. These variables are preconfigured as part of the environment when you deploy to Optimizely Frontend Hosting.

Register the webhook idempotently on application startup with scripts/register-webhook.mjs, and run it in the background from the start script before the server starts.

"start": "node scripts/register-webhook.mjs & exec node ./dist/server/entry.mjs"

The registration script lists existing webhooks for the callback URL to avoid duplicates, then creates the webhook if it does not find one, which makes the script safe to re-run on every container start.

// scripts/register-webhook.mjs
// Normalize the gateway URL by stripping a trailing slash and any /content/v2
// suffix, so a webhook registered anywhere else never fires.
const GRAPH_BASE = (process.env.OPTIMIZELY_GRAPH_GATEWAY ?? 'https://cg.optimizely.com')
  .replace(/\/+$/, '')
  .replace(/\/content\/v2$/, '');

await fetch(`${GRAPH_BASE}/api/webhooks`, {
  method: 'POST',
  headers: {
    Authorization: `Basic ${Buffer.from(`${APP_KEY}:${APP_SECRET}`).toString('base64')}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    disabled: false,
    // The callback is a nested `request` object (url, method, headers), not a
    // flat `callback` field.
    request: {
      url: `${SITE_HOSTNAME}/hooks/graph`,
      method: 'POST',
      headers: { 'x-api-key': CALLBACK_APIKEY },
    },
    // topic scopes which subject.action events Graph delivers. Omitting it can
    // register a webhook that receives nothing and never fires a callback.
    // '*.*' means all events, and the filters that follow narrow to Published.
    topic: ['*.*'],
    filters: [{ status: { eq: 'Published' } }],
  }),
});

The webhook is registered with a filter for Published status and includes an x-api-key header that Optimizely Graph sends with each callback. The callback handler uses this key to validate incoming requests. For more detail, see the Optimizely Graph webhook documentation.

Add the webhook callback handler

Create src/pages/hooks/graph.ts. The handler validates the request with the shared API key, resolves the content's URL path through Optimizely Graph, invalidates the corresponding cache entries, and purges the CDN cache for that URL.

// src/pages/hooks/graph.ts
// Graph content-publish webhook callback.
// URL: POST /hooks/graph, which matches the registration callback in
// scripts/register-webhook.mjs.
import type { APIRoute } from 'astro';
import { cacheProvider } from '../../lib/redis-cache-provider.ts';
import { purgeCdnCache } from '../../lib/cdn-cache.ts';
import { getGraphClient } from '../../lib/graph-client.ts';
import { RESOLVE_DOC_PATH } from '../../lib/graph-queries.ts';
import { verifyApiKey } from '../../lib/auth/verify-api-key.ts';

export const prerender = false;

const CALLBACK_APIKEY = process.env.OPTIMIZELY_GRAPH_CALLBACK_APIKEY ?? '';

// Normalize to an absolute URL, because the CDN purge API needs a full https://
// URL and OPTIMIZELY_SITE_HOSTNAME may be a bare host.
const rawHostname = process.env.OPTIMIZELY_SITE_HOSTNAME ?? '';
const SITE_HOSTNAME = rawHostname && !/^https?:\/\//i.test(rawHostname)
  ? `https://${rawHostname}`
  : rawHostname;

interface WebhookPayload {
  type?: { subject?: string; action?: string };
  data?: { docId?: string };
}

export const POST: APIRoute = async ({ request }) => {
  if (!verifyApiKey(request.headers.get('x-api-key'), CALLBACK_APIKEY)) {
    console.warn('[graph-webhook] unauthorized request: missing or invalid x-api-key');
    return new Response('Unauthorized', { status: 401 });
  }

  let payload: WebhookPayload;
  try {
    // Read as text first, so both application/json and text/plain are accepted.
    const parsed = JSON.parse(await request.text());
    if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
      return new Response('Bad Request: payload must be a JSON object', { status: 400 });
    }
    payload = parsed;
  } catch {
    return new Response('Bad Request: invalid JSON', { status: 400 });
  }

  const docId = payload?.data?.docId;
  if (!docId) {
    return new Response('Bad Request: missing data.docId', { status: 400 });
  }

  // docId format from Graph: "{guid-with-hyphens}_{locale}_{status}", such as
  // "7ac4c306-9085-4be4-975a-8f357c60b154_en_Published". _metadata.key stores
  // the same GUID without hyphens, so strip the suffixes and remove hyphens.
  const contentKey = docId.split('_')[0].replace(/-/g, '');

  // Resolve the content key to a URL path through Graph.
  let path: string | undefined;
  try {
    const result = await getGraphClient().request<{
      _Content: { items: Array<{ _metadata: { url: { default: string } } }> };
    }>(RESOLVE_DOC_PATH, { key: contentKey });
    path = result._Content?.items?.[0]?._metadata?.url?.default;
  } catch (err) {
    console.error('[graph-webhook] Graph query error:', err);
    return new Response('Internal Server Error: Graph query failed', { status: 500 });
  }

  if (!path) {
    console.warn(`[graph-webhook] Graph returned no path for key=${contentKey}.`);
    return new Response('Not Found: could not resolve docId to a path', { status: 404 });
  }

  // The frontend serves every content type at its real CMS path through the
  // catch-all route, so the resolved CMS URL is the request path that the Redis
  // cache and CDN are keyed by. The provider normalizes the trailing slash for
  // the Redis key, but the CDN caches the literal requested URL, so purge both
  // with and without the slash.
  const withSlash = path.endsWith('/') ? path : `${path}/`;
  const withoutSlash = path.replace(/\/+$/, '') || '/';

  // Deduplicate. If the changed page is the home page, do not act on '/' twice.
  const invalidatePaths = [...new Set([path, '/'])];
  const purgeUrls = [...new Set([withoutSlash, withSlash, '/'])].map((p) => `${SITE_HOSTNAME}${p}`);

  // Invalidate Redis and purge the CDN in parallel, and settle both so that one
  // failure does not block the other.
  const [redisResult, cdnResult] = await Promise.allSettled([
    Promise.all(invalidatePaths.map((p) => cacheProvider.invalidate({ path: p }))),
    purgeCdnCache(purgeUrls),
  ]);

  if (redisResult.status === 'rejected') {
    console.error('[graph-webhook] Redis invalidate failed:', redisResult.reason);
  }
  if (cdnResult.status === 'rejected') {
    console.error('[graph-webhook] CDN purge failed:', cdnResult.reason);
  }

  // Return only what the caller needs. Do not reflect untrusted payload fields.
  return new Response(JSON.stringify({ ok: true, path }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
  });
};

On Astro 5, the handler is identical except for the invalidation call. Import invalidate from ../../middleware.ts instead of cacheProvider, and call Promise.all(invalidatePaths.map((p) => invalidate(p))).

Also create src/lib/auth/verify-api-key.ts for the timing-safe key comparison.

// src/lib/auth/verify-api-key.ts
import { timingSafeEqual } from 'node:crypto';

/** Timing-safe API key check. Returns false if either argument is empty. */
export function verifyApiKey(provided: string | null, expected: string): boolean {
  if (!provided || !expected || provided.length !== expected.length) return false;
  return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
}

This performs targeted invalidation. Only the page that changed, plus the home page, is invalidated and purged from the CDN, and every other cached page is left untouched.

The home page is always included because it renders a live list of all pages. Any publish or unpublish makes its cached HTML stale, and without an explicit invalidation, a newly published page would not display there until the entry expired, which is 40 minutes with the TTLs in this guide. Other aggregate pages, such as listings and navigation, are not invalidated by the webhook. Those pages self-correct when their TTL lapses, which is why the TTLs are kept to 10 minutes fresh and 30 minutes stale rather than set to hours.

CDN cache purge

The webhook callback handler purges the CDN cache for the specific URL that changed. The Cloud Platform Services API exposes an edge cache purge endpoint, authenticated with Azure managed identities.

Create the managed identity credentials once in a shared helper at src/lib/auth/azure-identity.ts, and reuse them for every Azure-authenticated call.

// src/lib/auth/azure-identity.ts
import { ManagedIdentityCredential, type TokenCredential } from '@azure/identity';

// This sample is deployed to Azure and authenticates exclusively through the
// user-assigned managed identity. AZURE_CLIENT_ID selects which identity to use
// when more than one is assigned to the host.
export function getCredential(): TokenCredential {
  return new ManagedIdentityCredential({ clientId: process.env.AZURE_CLIENT_ID });
}

Create src/lib/cdn-cache.ts.

// src/lib/cdn-cache.ts
import { getCredential } from './auth/azure-identity.ts';

const API_URL = process.env.OPTIMIZELY_CLOUDPLATFORM_API_URL ?? '';
const API_RESOURCE_ID = process.env.OPTIMIZELY_CLOUDPLATFORM_API_RESOURCE_ID ?? '';

// 5-minute token cache, to avoid re-fetching on every webhook or API call.
let tokenCache: { token: string; expiresAt: number } | null = null;

export async function getToken(): Promise<string> {
  if (!API_RESOURCE_ID) {
    throw new Error('[cdn-cache] OPTIMIZELY_CLOUDPLATFORM_API_RESOURCE_ID is not set');
  }
  if (tokenCache && tokenCache.expiresAt > Date.now() + 60_000) {
    return tokenCache.token;
  }

  const credential = getCredential();
  const result = await credential.getToken(`${API_RESOURCE_ID}/.default`);
  tokenCache = { token: result.token, expiresAt: result.expiresOnTimestamp };
  return result.token;
}

export async function purgeCdnCache(urls: string[]): Promise<void> {
  // Fail fast with a clear message rather than letting getToken fail on an
  // empty scope.
  if (!API_URL || !API_RESOURCE_ID) {
    console.warn('[cdn-cache] OPTIMIZELY_CLOUDPLATFORM_API_URL or _RESOURCE_ID not set; purge skipped');
    return;
  }
  if (!urls.length) return;

  const token = await getToken();
  const res = await fetch(`${API_URL}/v1/edge-cache/purge`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ urls }),
  });

  if (!res.ok) {
    throw new Error(`CDN purge failed ${res.status}: ${await res.text()}`);
  }
}

The API returns 202 Accepted. The purge is asynchronous, so the CDN processes it in the background and clears the cache within seconds.

Environment variables reference

Optimizely provisions the following variables automatically when you deploy to Optimizely DXP:

  • OPTIMIZELY_GRAPH_SINGLE_KEY – Optimizely Graph read-only key for querying published content.
  • OPTIMIZELY_GRAPH_GATEWAY – Optimizely Graph gateway URL (for example, https://cg.optimizely.com).
  • OPTIMIZELY_GRAPH_APP_KEY – Optimizely Graph API key for webhook management (Basic auth username).
  • OPTIMIZELY_GRAPH_SECRET – Optimizely Graph secret for webhook management (from Key Vault).
  • OPTIMIZELY_GRAPH_CALLBACK_APIKEY – Shared secret for authenticating incoming webhook requests (from Key Vault).
  • OPTIMIZELY_CMS_URL – CMS instance URL (for example, https://app-abcd11111.cms.optimizely.com).
  • OPTIMIZELY_SITE_HOSTNAME – Public hostname of the site (for example, https://mysite.example.com).
  • REDIS_URL – Azure Cache for Redis hostname and port (for example, rediss://myredis.redis.azure.net:10000).
  • AZURE_CLIENT_ID – Managed identity client ID for Redis and CDN authentication.
  • OPTIMIZELY_DXP_DEPLOYMENT_ID – Unique ID for the deployment slot, used for cache key namespacing.
  • OPTIMIZELY_CLOUDPLATFORM_API_URL – Cloud Platform Services API base URL, used for the CDN purge.
  • OPTIMIZELY_CLOUDPLATFORM_API_RESOURCE_ID – Azure resource ID for managed identity token scoping, used for CDN purge authentication.

Did this page help you?