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
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:
- Authenticate the request – only logged‑in editors may fetch drafts.
- Bypass ISR caching – the preview request must always hit Strapi for the latest draft.
- Invalidate ISR cache – after a draft change, purge any cached preview pages so subsequent requests see the update instantly.
- 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
| Requirement | Version |
|---|---|
| Strapi | 5.x (installed with the GraphQL plugin) |
| Node.js | 18 – 24 |
| Next.js | 15.x |
| Database | PostgreSQL, MySQL or SQLite |
| JWT secret | Configured 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:
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:
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:
npm i @apollo/client graphqlCreate a singleton client that uses Strapi’s documentId as the cache identifier:
// 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:
// 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:
- Reads the
jwtquery parameter supplied by the editor UI. - Calls Strapi’s internal
/admin/jwt/verifyendpoint to ensure the token is still valid. - Sets two cookies:
next-preview(enables Next.js preview mode) andstrapi-jwt(used by the client to authenticate GraphQL requests). - Calls
revalidateTag('preview')to clear any ISR pages that were previously generated with thepreviewtag. - 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:
https://your‑next‑site.com/api/preview?jwt=<YOUR_JWT>&to=/articles/123You can obtain the JWT via the GraphQL login mutation:
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.
// 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
Authorizationheader 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:
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
});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.
// app/articles/[id]/page.tsx (continued)
export const revalidate = 60; // 1 minute ISR for published contentWhen 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
| Step | Why it matters |
|---|---|
Verify JWT with Strapi’s /admin/jwt/verify endpoint | Prevents token replay attacks and ensures the token has not been revoked. |
| Store JWT in an httpOnly cookie | Stops client‑side JavaScript from reading the token, mitigating XSS leakage. |
Set sameSite: 'lax' and a short maxAge | Limits the window for CSRF and token theft. |
Use fetchPolicy: 'network-only' for drafts | Guarantees that a cached published version is never served to an editor. |
Tag preview fetches with 'preview' and call revalidateTag | Guarantees immediate cache purge after any draft change. |
| Keep the Edge route stateless | No 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.
{
"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
- Unit test the JWT verification – mock the
/admin/jwt/verifyendpoint and assert the route returns 401 on invalid tokens. - 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.
- 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
# 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
Sources
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.