Using Next.js Preview mode with Strapi CMS

Thursday, March 4, 2021
7 min read

This article is a loose continuation of 'How to trigger a Next.js rebuild from Strapi CMS' - as the website is statically generated any content being added through the Strapi CMS cannot be viewed as the relevant static page doesn't exist. For example the following news post exists but if you try you should get a '404 | This page could not be found.'.

To give any content editors a better experience I wanted to take advantage of Next.js preview mode with a link in the CMS:


  • Node (tested with v14)
  • NPM (tested with v7)
  • Next.JS website hosted on Vercel
  • Strapi CMS setup (my POC is hosted on render)


Preview API

Within Next.js we need to add a API route, if you haven't used one before then have a read up of API routes on the Next.js docs.

First add a .env file, I have one for local and prodcution:

  • .env.development.local
  • .env.production.local

In there add a secret:


We use the secret to compare with the one sent from the CMS.

Next, in /pages/api add a new page called preview.js (the name can be anything you want - just make sure you change the relevant URL's when calling it).

export default async (req, res) => {
  // Check the secret and next parameters
  // This secret should only be known to this API route and the CMS
  if (req.query.secret !== process.env.STRAPI_PREVIEW_SECRET || ! {
    return res.status(401).json({ message: 'Invalid token' });

  // Enable Preview Mode by setting the cookies

  // Redirect to the path from the fetched post
  // We don't redirect to req.query.slug as that might lead to open redirect vulnerabilities
  res.redirect(307, `/news/${}`);

Add the code above. First we check the Next.js secret matches the one from the CMS.

if (req.query.secret !== process.env.STRAPI_PREVIEW_SECRET || ! {
  return res.status(401).json({ message: 'Invalid token' });

Next.js loads anything in the .env into the request object. Now check there is a id. I'm using id for now, however in a real world application/website I'd use a slug like the Next.js docs example uses. If either of these checks fails the response fails with a 401.

res.setPreviewData({}) allows us to pass any data. It's worth noting that

because the data will be stored in a cookie, there’s a size limitation. Currently, preview data is limited to 2KB".

I tried to pass the entire post object which failed due to the above limit. Its always worth reading the documentation properly 😂

The last bit of code res.redirect(307, /news/${}) redirects to the correct path with the relevant cookies set. In a real world scenario I wouldn't hard code the /news/ pathname and have that be dynamic in some way to cater for different pathnames/content.

You can test you can call the URL using https://<your-site>/api/preview?secret=<token>&id=<id>

Modify post page

In my test website I have dynamic route /pages/news/[id].js for displaying the articles. I won't go through each line of code but just discuss the small changes I made to enable preview mode.

In /pages/news/[id].js I added a preview argument which I pass to my getNewsItem function

export async function getStaticProps(context) {
  const { params, preview } = context;
  const item = await getNewsItem(, preview);

  if (!item) {
    return { notFound: true };

  const mdxSource = await renderToString(item?.Body ?? '');

  return {
    props: {
      item: {
      preview: preview ? true : null,

With that in place I can check for the argument and append the query variable to the URL. ?_publicationState=preview is specific to Strapi, it would need modifying for other headless CMS's.

// lib/news.js
const getNewsItem = async (id, preview = false) => {
  try {
    // check for preview mode, add required query parameter if we are in preview mode
    const res = await fetch(
      `${process.env.NEXT_PUBLIC_API_URL}/news-items/${id}${preview ? '?_publicationState=preview' : ''}`,

    if (res.status !== 200) {
      throw new Error('Error retrieving news item');

    const data = await res.json();

    return data;
  } catch (e) {
    console.error( + ': ' + e.message);

export { getNewsItem };

If you try to access the news item directly (e.g. without the cookies set Strapi will return a 500 error therefore I check the status:

if (res.status !== 200) {
  throw new Error('Error retrieving news item');

And throw an error. The dynamic route /pages/news/[id].js will end up with undefined for the item:

if (!item) {
  return { notFound: true };

In this case a 404 is shown on the website.

That's as much as we need for the front end. There is an optional step below for exiting preview mode.

Exit Preview Mode

By default the preview mode as no experation date, it ends when the session ends (browser closes). To manually end, add a new API route called exit-preview.js :

// pages/api/exit-preview.js
export default function handler(req, res) {
  // Clears the preview mode cookies.
  // This function accepts no arguments.


Calling clearPreviewData will clear any preview cookies. Back in pages/news/[id].js add a button with a click handler

<button onClick={() => exitPreviewMode()}> turn off</button>

I've got a nice banner with a text button but I'll leave the actual UI implementation to you

The exitPreviewMode calls the API endpoint. I envisage a scenario whereby the content editor will click the link from the CMS hence window.close() to close the window/tab and take the content editor back to the CMS.

async function exitPreviewMode() {
  const res = await fetch('/api/exit-preview').catch((err) => console.error(err));

  if (res) {


In production I am hosting the website on Vercel, any environment variables will need adding into the hosting environment.


The Srapi side is a little less clear for me as I'm not as comfortable on that side. Also, I couldn't seem to find much documentation on enabling preview mode. After much trial and error, I managed to get it working using the following resources:

Locally, add a .env file in the root


The secret needs to match the one set in Next.js (STRAPI_PREVIEW_SECRET). The FRONTEND_URL is the next.js local development hostname.

As per the issue on GitHub create the following directories content-manager/admin/src into the /extensions directory making sure to add the content from content-manager/admin/src

In /extensions/content-manager/admin/src/InjectedComponents/PreviewURL/index.js I edited the PreviewUrl function from the one of Github changing the URL to use the id

// extensions/content-manager/admin/src/InjectedComponents/PreviewURL/index.js

// if (slug !== "" || id === "create") {
//   return null;
// }
// Build the right preview URL based on the page status
const previewURL = `${FRONTEND_URL}/api/preview?secret=${FRONTEND_PREVIEW_SECRET}&id=${id}`;

The commented-out code allows draft posts to be previewed irrespective of publishing state and in the future, I'd also like editors to be able to preview pages therefore I've also removed this check slug !== "" .

The big thing that caught me out is I had to modify the Webpack config and push in the environment variables

// admin/admin.config.js
module.exports = {
  webpack: (config, webpack) => {
    // Note: we provide webpack above so you should not `require` it
    // Perform customizations to webpack config
    // Important: return the modified config

      new webpack.DefinePlugin({
        FRONTEND_URL: JSON.stringify(process.env.FRONTEND_URL || 'http://localhost:3000'),
        FRONTEND_PREVIEW_SECRET: JSON.stringify(process.env.FRONTEND_PREVIEW_SECRET || 'secret-token'),

    return config;

I honestly don't know why that works, I spent several frustrating hours trying to get the preview to work. Until I added the above (and rebuilt the admin) the preview button wouldn't show in the CMS admin. I'll need to spend some time researching custom extensions/plugins building some for Strapi to understand whats's going on.

Going back to building the admin, for any changes to take effect, the admin needs to be rebuilt by running npm run build in the root of strapi.


In production I'm hosting with render, through their dashboard I've added the FRONTEND_URL and FRONTEND_PREVIEW_SECRET . The secret matches the one added to Vercel with teh URL matching the deployed website URL on vercerl.



Find me on