Skip to content

Secure Real‑Time Preview Mode in Next.js 15 with Strapi 5 GraphQL and Edge Cache Invalidation

Learn step‑by‑step how to build a secure preview mode in Next.js 15 that pulls draft content from Strapi 5 via GraphQL, skips ISR caching, and triggers edge cache invalidation using JWT authentication.

By the Revisionary team · · 5 min read

a computer screen with a program running on it
Photo by James Wiseman on Unsplash

Direct answer: Create an Edge API route that verifies a Strapi JWT, sets the Next.js preview cookie, and immediately invalidates any ISR‑cached pages tagged for preview. Then fetch draft content in your page components with fetch(..., { next: { revalidate: 0, tags: ['preview'] } }) or with Apollo Client configured to use Strapi’s documentId as the cache key. This combination guarantees that only authenticated users see the latest draft, bypasses static caching, and clears stale pages in real time.


1. Why a custom preview workflow is needed

Strapi 5 exposes both published and draft versions of a document. The REST API lets you query ?status=draft, while the GraphQL plugin adds a documentId field that identifies the same entity across versions. In a typical Next.js ISR setup, pages are statically generated and cached for a configurable interval. When an editor publishes or updates a draft, the cached page would continue to serve the old content until the next revalidation. A secure preview mode must therefore:

  1. Authenticate the request – only logged‑in editors may fetch drafts.
  2. Bypass ISR caching – the preview request must always hit Strapi for the latest draft.
  3. Invalidate ISR cache – after a draft change, purge any cached preview pages so subsequent requests see the update instantly.
  4. Run at the edge – low latency and no reliance on a server‑side Node process.

Next.js 15 provides built‑in support for preview cookies, edge API routes, and tag‑based cache invalidation, making it the ideal platform for this workflow.


2. Prerequisites

RequirementVersion
Strapi5.x (installed with the GraphQL plugin)
Node.js18 – 24
Next.js15.x
DatabasePostgreSQL, MySQL or SQLite
JWT secretConfigured in Strapi (JWT_SECRET)

Make sure you have a Strapi admin user that can generate JWT tokens via the login mutation (see the GraphQL docs). Also enable the Revisionary plugin if you want a full content‑history view, though it is not required for preview.


3. Configure Strapi GraphQL for draft access

3.1 Enable the GraphQL plugin

Add the plugin to config/plugins.ts:

ts
export default {
  graphql: {
    endpoint: '/graphql',
    shadowCRUD: true, // generate CRUD resolvers automatically
    apolloServer: {
      // default is 'bounded'; we keep it
    },
  },
};

3.2 Allow draft queries

Strapi automatically resolves the status argument on the generated queries. You can request drafts like this:

graphql
query GetArticleDraft($id: ID!) {
  article(id: $id, status: "draft") {
    documentId
    title
    content
    updatedAt
  }
}

The documentId field is crucial for Apollo Client cache normalization (see next section).


4. Set up Apollo Client in Next.js

Install the client:

bash
npm i @apollo/client graphql

Create a singleton client that uses Strapi’s documentId as the cache identifier:

ts
// lib/apolloClient.ts
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';

const httpLink = new HttpLink({
  uri: process.env.NEXT_PUBLIC_STRAPI_GRAPHQL_ENDPOINT,
  headers: {
    // The JWT will be added per‑request in the preview route
  },
});

export const apolloClient = new ApolloClient({
  link: httpLink,
  cache: new InMemoryCache({
    typePolicies: {
      // Use Strapi's documentId for identity across draft/published
      Query: {
        fields: {
          article: {
            read(_, { readField }) {
              return readField('documentId');
            },
          },
        },
      },
      Article: {
        keyFields: ['documentId'],
      },
    },
  }),
});

When the preview cookie is present, the edge route will attach the JWT header, so the client does not need to set it manually.


5. Create the Edge preview API route

Next.js 15 edge routes run at the CDN edge, have minimal cold‑start time, and can set cookies that are sent on subsequent page requests.

Create the file app/api/preview/route.ts:

ts
// app/api/preview/route.ts
import { NextResponse } from 'next/server';
import { revalidateTag } from 'next/cache';

export const runtime = 'edge'; // ensures edge execution

// Helper: verify JWT against Strapi's public key (or introspect via /admin)
async function verifyJwt(token: string): Promise<boolean> {
  const res = await fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/admin/jwt/verify`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ token }),
  });
  return res.ok;
}

export async function GET(request: Request) {
  const url = new URL(request.url);
  const token = url.searchParams.get('jwt');
  const redirectTo = url.searchParams.get('to') ?? '/';

  if (!token) {
    return new NextResponse('Missing JWT', { status: 400 });
  }

  const isValid = await verifyJwt(token);
  if (!isValid) {
    return new NextResponse('Invalid JWT', { status: 401 });
  }

  // Set the preview cookie (default name is __next_preview_data)
  const response = NextResponse.redirect(redirectTo);
  response.cookies.set('next-preview', '1', {
    httpOnly: true,
    path: '/',
    maxAge: 60 * 60, // 1 hour
    sameSite: 'lax',
  });

  // Store the JWT in a separate cookie for the client‑side fetches
  response.cookies.set('strapi-jwt', token, {
    httpOnly: true,
    path: '/',
    maxAge: 60 * 60,
    sameSite: 'lax',
  });

  // Immediately purge ISR‑cached preview pages
  revalidateTag('preview');

  return response;
}

What the route does:

  1. Reads the jwt query parameter supplied by the editor UI.
  2. Calls Strapi’s internal /admin/jwt/verify endpoint to ensure the token is still valid.
  3. Sets two cookies: next-preview (enables Next.js preview mode) and strapi-jwt (used by the client to authenticate GraphQL requests).
  4. Calls revalidateTag('preview') to clear any ISR pages that were previously generated with the preview tag.
  5. Redirects the editor back to the page they were previewing.

6. Trigger the preview route from the Strapi UI

Add a small button in the Strapi admin toolbar (or a custom webhook) that opens the preview URL:

code
https://your‑next‑site.com/api/preview?jwt=<YOUR_JWT>&to=/articles/123

You can obtain the JWT via the GraphQL login mutation:

graphql
mutation Login($identifier: String!, $password: String!) {
  login(input: { identifier: $identifier, password: $password }) {
    jwt
  }
}

Store the token temporarily in the browser (e.g., sessionStorage) and build the URL dynamically.


7. Fetch draft content in a Next.js page component

In the page that renders an article, use the next fetch options to bypass ISR and tag the request for preview invalidation.

tsx
// app/articles/[id]/page.tsx
import { gql } from '@apollo/client';
import { apolloClient } from '@/lib/apolloClient';

const ARTICLE_DRAFT = gql`
  query Article($id: ID!) {
    article(id: $id, status: "draft") {
      documentId
      title
      content
    }
  }
`;

export default async function ArticlePage({ params }: { params: { id: string } }) {
  // Detect preview mode via the cookie (Next.js sets `preview` flag automatically)
  const isPreview = !!process.env.NEXT_PUBLIC_PREVIEW_MODE && !!(await import('next/headers')).cookies().get('next-preview');

  const { data } = await apolloClient.query({
    query: ARTICLE_DRAFT,
    variables: { id: params.id },
    fetchPolicy: 'network-only', // always hit Strapi for drafts
    context: {
      // Attach JWT from the cookie when in preview mode
      headers: isPreview
        ? { Authorization: `Bearer ${await import('next/headers').cookies().get('strapi-jwt')?.value}` }
        : {},
    },
  });

  const article = data.article;

  return (
    <article>
      <h1>{article.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: article.content }} />
    </article>
  );
}

Key points:

  • fetchPolicy: 'network-only' forces Apollo to bypass its cache, guaranteeing the latest draft.
  • The request includes the Authorization header only when the preview cookie is present.
  • Because the page component runs on the edge (default for Next.js 15 app router), the request latency is minimal.

If you prefer the built‑in fetch API instead of Apollo, you can use:

ts
const res = await fetch(`${process.env.NEXT_PUBLIC_STRAPI_GRAPHQL_ENDPOINT}`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    ...(isPreview && { Authorization: `Bearer ${jwt}` }),
  },
  body: JSON.stringify({ query: ARTICLE_DRAFT, variables: { id: params.id } }),
  next: { revalidate: 0, tags: ['preview'] }, // bypass ISR and tag for invalidation
});

a computer screen with a lot of data on it
Photo by Lukas on Unsplash

8. Edge‑aware ISR for non‑preview traffic

For regular visitors you still want ISR to keep the site fast. Create a separate route that uses the default revalidate interval and does not set the preview tag.

tsx
// app/articles/[id]/page.tsx (continued)
export const revalidate = 60; // 1 minute ISR for published content

When the page is rendered in preview mode, the next options { revalidate: 0, tags: ['preview'] } override the static revalidate value, ensuring the draft is always fresh.


9. Security checklist

StepWhy it matters
Verify JWT with Strapi’s /admin/jwt/verify endpointPrevents token replay attacks and ensures the token has not been revoked.
Store JWT in an httpOnly cookieStops client‑side JavaScript from reading the token, mitigating XSS leakage.
Set sameSite: 'lax' and a short maxAgeLimits the window for CSRF and token theft.
Use fetchPolicy: 'network-only' for draftsGuarantees that a cached published version is never served to an editor.
Tag preview fetches with 'preview' and call revalidateTagGuarantees immediate cache purge after any draft change.
Keep the Edge route statelessNo server‑side session data means the route scales automatically.

10. Optional: Hooking into Strapi content‑change events

If you want the preview cache to clear automatically when an editor saves a draft, add a webhook in Strapi that calls the same Edge route (POST) with the JWT and the affected slug.

json
{
  "event": "entry.save",
  "url": "https://your‑next‑site.com/api/preview?jwt=<JWT>&to=/articles/{{entry.id}}",
  "method": "GET"
}

The webhook fires on every save, the Edge route validates the token again, and revalidateTag('preview') clears the stale page.


11. Testing the preview flow

  1. Unit test the JWT verification – mock the /admin/jwt/verify endpoint and assert the route returns 401 on invalid tokens.
  2. End‑to‑end test with Playwright – log in to Strapi, obtain a JWT, navigate to the preview URL, and assert the rendered page shows the draft content while the normal public URL still shows the published version.
  3. Cache‑invalidated test – after editing the draft, trigger the webhook or manually refresh the preview URL and verify the page updates without a full reload.

12. Where Revisionary fits

If your project requires a full audit trail of every draft change, enable the Revisionary plugin. It stores field‑level history, lets you compare versions, and provides an API (/content-history) that can be read from the front end if you need to display a “last edited” banner in preview mode. The plugin is a paid add‑on, installed as a zip and activated in Strapi’s plugin manager. See the /docs/reference/content-api page for details on exposing history through the API.


13. Full code summary

code
# config/plugins.ts (Strapi)
export default {
  graphql: { endpoint: '/graphql', shadowCRUD: true },
};

# lib/apolloClient.ts (Next.js)
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';
export const apolloClient = new ApolloClient({
  link: new HttpLink({ uri: process.env.NEXT_PUBLIC_STRAPI_GRAPHQL_ENDPOINT }),
  cache: new InMemoryCache({ typePolicies: { Article: { keyFields: ['documentId'] } } }),
});

# app/api/preview/route.ts (Edge API)
export const runtime = 'edge';
export async function GET(req: Request) { /* …see section 5 */ }

# app/articles/[id]/page.tsx (Page component)
export const revalidate = 60;
export default async function ArticlePage({ params }) { /* …see section 7 */ }

14. Conclusion

By combining Strapi 5’s GraphQL draft access, JWT authentication, and Next.js 15 edge preview routes with tag‑based cache invalidation, you get a secure, real‑time preview that never leaks unpublished content and always reflects the latest edits. The pattern is framework‑agnostic: the same concepts work with REST endpoints or other headless CMSs, but Strapi’s documentId and built‑in JWT verification make the integration especially smooth.


Further reading

  • Strapi GraphQL plugin documentation – /docs/reference/content-api
  • Next.js 15 caching guide – /docs/getting-started (internal link placeholder)
  • Revisionary feature overview – /docs/everyday-use/panel
TagsNext.jsStrapiGraphQLPreview ModeISREdge Functions

Sources

  1. Strapi 5 REST API documentation
  2. Strapi 5 GraphQL plugin documentation
  3. Next.js 15 caching and revalidation guide
  4. Next.js 15 preview mode guide
  5. Next.js Edge API routes documentation
  6. Strapi content‑history feature documentation

The Revisionary team

Developers who build Revisionary, a version history plugin for Strapi 5. We write about Strapi, web development, AI and application security.

This article was written with the help of AI.

All articles