In this tutorial, you’ll learn to implement a preview system when working with a headless CMS like Strapi.
Author: Mark Munyaka
There is an ongoing shift in content management from traditional CMS to headless CMS. A headless CMS allows you to completely separate your content management system from the presentation layer. The content is made available via API and can be consumed in any kind of frontend, from websites to mobile apps.
Using headless CMS has opened up a new way of building websites, known as pre-rendering. It is one of the best-known techniques in Jamstack, in which the website is compiled into a set of static assets like pre-built HTML, CSS, and JavaScript files with the help of a static site generator (SSG). During the build time, the files are created by collecting the data from a headless CMS. These files are cached to a content delivery network (CDN) and served to a user on each request from the nearest CDN node. This improves speed and response times and reduces hosting costs.
However, content creators need to preview their content before publishing it to production, meaning they need to wait for an entire build to complete before they can view their content. To solve this problem, a preview mode allows editors to view their changes on the fly.
In this tutorial, you’ll learn to implement a preview system when working with a headless CMS like Strapi. You’ll implement the frontend in Next.js for creating content previews.
Prerequisites
Here are what you’ll need to get started:
- Node.js: This tutorial uses Node v16.1.x
- Strapi: This tutorial uses Strapi v4.2.x
- Next.js: This tutorial uses Next.js v12.2.x
Setting up the Project
You’ll need a master directory that holds the code for both the frontend (Next.js) and backend (Strapi). Open your terminal, navigate to a path of your choice, and create a project directory by running the following command:
mkdir strapi-nextjs-previews
In the strapi-nextjs-previews
directory, you’ll install both Strapi and Next.js projects.
Setting up Strapi
In your terminal, execute the following command to create the Strapi project:
npx create-strapi-app backend --quickstart --template @strapi/template-blog@1.0.0 blog
This creates a directory named backend
and contains all the code related to your Strapi project. It uses the preconfigured Strapi template for blogs.
Your Strapi project will start on port 1337 and open localhost:1337/admin/auth/register-admin in your browser. Set up your administrative user:
Enter your details and click the Let’s Start button.
You’ll be taken to the Strapi dashboard. Select the Content Manager header on the left sidebar, then, click the Article tab under the COLLECTION TYPES menu for pre-written articles you can access.
All the articles in your Strapi CMS are in the published state. Since the goal is to view the unpublished content, move some of the articles to the draft state.
Click the blog with id "1". You’ll be taken to the edit view screen:
Click the Unpublish button at the top of the screen to change the article into a draft.
Now, you have five published articles and one draft article. Next, you’ll learn how to fetch these articles via Strapi API and display them on a Next.js website.
Setting up Next.js
It’s time to build the frontend website using Next.js. Since your current terminal window is serving the Strapi project, open another terminal window and execute the following command to create a Next.js project:
npx create-next-app frontend
This creates a directory named frontend
containing all the code related to the Next.js project. Navigate into the directory and start the Next.js development server by running the following commands:
cd frontend
npm run dev
This will start the development server on port 3000 and take you to localhost:3000. The first view of the Next.js website will look like this:
Setting up the UI Framework
Now, add Bootstrap to style your blog. You can actually choose any design framework, but the installation steps may vary.
Shut down the Next.js development server by pressing Ctrl-C in your terminal and execute the following command to install bootstrap, react-bootstrap, and node-sass NPM packages for your Next.js website:
npm install bootstrap react-bootstrap node-sass --save
Once the installation is complete, open the next.config.js
file and add the following code:
const path = require('path');
module.exports = {
reactStrictMode: true,
sassOptions: {
includePaths: [path.join(__dirname, 'styles')],
},
};
This tells the node-sass to compile the SCSS to CSS from the styles
directory.
In the styles
directory:
- Rename the
globals.css
toglobals.scss
. - Replace the existing styles in
globals.scss
with the following line of code:
@import '/node_modules/bootstrap/scss/bootstrap.scss';
In the pages
directory, open the _app.js
file and replace the existing code with the following:
import '/styles/globals.scss';
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />;
}
export default MyApp;
The above steps will complete the Bootstrap setup in your Next.js website.
You can learn more about customizing the Bootstrap in Next.js here.
Setting up the Markdown to HTML Converter
In the Strapi CMS, the article content is written in Markdown, so you need a way to convert Markdown into HTML. For this, you can use the react-showdown NPM package.
To install react-showdown, execute the following command in your terminal:
npm install react-showdown --save
Designing Articles Page
Now that you have set up the necessary packages for developing your Next.js website, you need to design an articles
page. It will fetch your articles from Strapi CMS and display them in the UI. In the pages
directory, create an articles
directory by running the following command:
mkdir pages/articles
In the articles
directory, create an index.js
file and add the following code:
import { Container, Row, Col } from 'react-bootstrap';
import { fetchArticlesApi } from '/lib/articles';
import Link from 'next/link';
const ArticlesView = (props) => {
// 2
const { articles } = props;
// 3
return (
<section className="py-5">
<Container>
<Row>
<Col lg="7" className="mx-lg-auto">
<h1 className="mb-5 border-bottom">Articles</h1>
{articles.data.map((article) => {
return (
<div key={article.id} className="mb-4">
<h2 className="h5">{article.attributes.title}</h2>
<p className="mb-2">{article.attributes.description}</p>
<Link href={'/articles/' + article.attributes.slug}>
<a className="">Read More</a>
</Link>
</div>
);
})}
</Col>
</Row>
</Container>
</section>
);
};
export async function getStaticProps() {
// 1
const articles = await fetchArticlesApi();
return {
props: { articles },
};
}
export default ArticlesView;
In the above code:
- You use the
fetchArticlesApi
function in thegetStaticProps
function provided by Next.js. In thegetStaticProps
function, you fetch the articles from the Strapi CMS and return them as a prop. - The
articles
array is destructured from theprop
variable in theArticlesView
. - The
articles
are displayed in the UI.
The next step is to write code to fetch articles using Strapi API. For that, you need to implement the fetchArticlesApi
function.
Create the lib
directory under the frontend
directory by running the following command:
mkdir lib
In the lib
directory, create an articles.js
file and add the following code:
const STRAPI_URL = process.env.STRAPI_URL;
// Helper function to GET the articles from Strapi
async function fetchArticlesApi() {
const requestUrl = `${STRAPI_URL}/articles`;
const response = await fetch(requestUrl);
return await response.json();
}
// Helper function to GET a single article from Strapi
async function fetchArticleApi(slug) {
const requestUrl = `${STRAPI_URL}/articles/?filters\[slug\][$eq]=${slug}`;
const response = await fetch(requestUrl);
return await response.json();
}
// Helper function to GET a single article from Strapi which is in draft state
async function fetchArticlePreviewApi(slug) {
const requestUrl = `${STRAPI_URL}/articles?publicationState=preview&filters\[slug\][$eq]=${slug}`;
const response = await fetch(requestUrl);
return (await response.json())[0];
}
export { fetchArticlesApi, fetchArticleApi, fetchArticlePreviewApi };
In the above code, you have declared two functions:
- The
fetchArticlesApi
function is used to fetch the published articles from the Strapi articles’ endpoint, localhost:1337/api/articles. - The
fetchArticleApi
function is used to fetch a single article from Strapi based on theslug
passed as a parameter. It calls the Strapi single article endpoint, i.e. localhost:1337/api/articles/{slug}, and returns the fetched article. - The
fetchArticlePreviewApi
function is used to fetch a single article when the preview mode is enabled. It calls the Strapi articles’ endpoint, ie localhost:1337/api/articles, and passes a query parameterpublicationState
whose value is set topreview
. You usepreview
when you want those articles that are either in published or draft state. Since you need only one article, theslug
parameter is used to search for an article by its slug. The response from Strapi API is an array, so you fetch the first item in theresponse
. Each article has a unique slug, so theresponse
array will contain a single item.
Since you have referred to the STRAPI_URL
as an environment variable, you need to add it to a .env
file. To do so, create a .env
file at the root of your Next.js project and add the following secret to it:
STRAPI_URL=http://localhost:1337/api
Save your progress and start your Next.js development server by running:
npm run dev
Visit localhost:3000/articles and you’ll see your articles page rendered by Next.js:
Although you have six articles in Strapi CMS, you’ll only see five since one article is in draft state.
Designing a Single Article Page
The next step is to design a single article page that needs to be dynamic. You can fetch your article using Strapi API based on the slug parameter provided in the URL path.
In the pages/articles
directory, create a [slug].js
file and add the following code:
import MarkdownView from 'react-showdown';
import { Container, Row, Col } from 'react-bootstrap';
import { fetchArticlesApi, fetchArticleApi } from '/lib/articles';
const ArticleView = (props) => {
const { article } = props;
return (
<section className="py-5">
<Container>
<Row>
<Col lg="7" className="mx-lg-auto">
<h1>{article.data[0].attributes.title}</h1>
<MarkdownView markdown={article.data[0].attributes.content} />
</Col>
</Row>
</Container>
</section>
);
};
// 1
export async function getStaticPaths() {
const articles = await fetchArticlesApi();
const paths = articles.data.map((article) => ({ params: { slug: article.attributes.slug } }));
return {
paths: paths,
fallback: false,
};
}
export async function getStaticProps(context) {
// 2
const slug = context.params.slug;
if (!slug) {
throw new Error('Slug not valid');
}
// 3
const article = await fetchArticleApi(slug);
if (!article) {
return { notFound: true };
}
// 4
return { props: { article } };
}
export default ArticleView;
In the above code:
- You need to return a list of all possible values for the slug as Next.js needs to build a dynamic route. In the
getStaticPaths
function, you make a call to thefetchArticlesApi
function, fetch all the published articles, and map their slugs to thepath
array. - In the
getStaticProps
function, you get theslug
from the URL params. If theslug
is not valid, you throw an error saying “Slug not valid.” - You fetch an
article
based on theslug
parameter by calling thefetchArticleApi
. You redirect the user to the not found page in case the article is not valid. - The
article
is passed as a prop, which is then used in theArticleView
.
Save your progress. Click Read More on any article to open the single article page:
Enabling the Preview Mode
Now that your blog is designed, you need to configure the Next.js Preview API for viewing the drafts on your Next.js website.
In your Next.js project, add the following secret to the .env
file:
CLIENT_PREVIEW_SECRET=<long random string>
The CLIENT_PREVIEW_SECRET
variable is used to securely access the preview mode in the browser. It is passed as a query parameter to the preview endpoint when you want to enable the preview mode.
In the pages/api
directory, create a preview.js
file and add the following code:
import { fetchArticlePreviewApi } from '/lib/articles';
export default async function handler(req, res) {
// Get the preview secret and the slug which needs to be previewed
const secret = req.query.secret;
const slug = req.query.slug;
// If the secret passed as URL query parameter doesn't match the preview secret in .env
// then send a 401-Unauthorized response
if (secret !== process.env.CLIENT_PREVIEW_SECRET) {
return res.status(401).json({ message: 'Invalid token' });
}
// If slug is not provided, send a 400-Bad Request response
if (!slug) {
return res.status(400).json({ message: 'Parameter `slug` is not provided' });
}
// Send a request to Strapi and fetch the article
// to check if the provided slug exists
const article = await fetchArticlePreviewApi(slug);
// If the article is not found, send a 404-Not Found response
if (article === null) {
return res.status(404).json({ message: 'Article not found' });
}
// Enable Preview Mode by setting the cookies
res.setPreviewData({});
// Redirect to the path of the article slug
res.redirect(307, `/articles/${article.data[0].attributes.slug}`);
}
In the above code:
- You destructure the
secret
andslug
from thereq.query
object. - You check whether the query parameter
secret
matches the preview secret (CLIENT_PREVIEW_SECRET
). - You make sure that the
slug
is notnull
. In case it is, you send back a bad request response (res.status(400)
). - You fetch the
article
associated with the requested slug by calling thefetchArticlePreviewApi
function. You pass theslug
parameter and fetch the articles inpreview
mode. - You make sure that the fetched
article
is notnull
. In case it is, you send back a not found response (res.status(404)
). - After verifying the request, you call the
res.setPreviewData({})
function, which is provided by the Next.js Preview API. It sets cookies in your browser which are valid for the current session. - You redirect (
res.redirect()
) the user to the requested article page (/articles/${article.data[0].attributes.slug}
) using a 307 redirect.
The cookies set by preview mode API remain in the browser as long as you don’t close the browser window. You might need an API endpoint to turn off the preview mode manually and clear all cookies.
In the pages/api
directory, create an exit-preview.js
file and add the following code:
export default function handler(req, res) {
// Clear the cookies set by the preview mode.
res.clearPreviewData();
// Send a 200-Success response to the frontend
res.status(200).end();
}
In the above code:
- You call the
res.clearPreviewData()
function to delete the cookies set by the preview mode API. - You send back a success response using
res.status(200).end()
.
Now you need to update the single article page to implement the preview mode functionality. Open the pages/articles/[slug].js
file and update it to match the following code:
import Router from 'next/router';
import MarkdownView from 'react-showdown';
import { Container, Row, Col } from 'react-bootstrap';
import { fetchArticlesApi, fetchArticleApi, fetchArticlePreviewApi } from '/lib/articles';
const ArticleView = (props) => {
// 4
const { article, previewMode } = props
return (
<section className="py-5">
<Container>
<Row>
<Col lg="7" className="mx-lg-auto">
{/* 5 */}
{previewMode ? (
<div className="small text-muted border-bottom mb-3">
<span>You are currently viewing in Preview Mode. </span>
<a role="button" className="text-primary" onClick={() => exitPreviewMode()}>
Turn Off Preview Mode
</a>
</div>
) : (
''
)}
<h1>{article.data[0].attributes.title}</h1>
<MarkdownView markdown={article.data[0].attributes.content} />
</Col>
</Row>
</Container>
</section>
);
};
// 6
async function exitPreviewMode() {
const response = await fetch('/api/exit-preview');
if (response) {
Router.reload(window.location.pathname);
}
}
export async function getStaticPaths() {
const articles = await fetchArticlesApi();
const paths = articles.data.map((article) => ({ params: { slug: article.attributes.slug } }));
return {
paths: paths,
fallback: false,
};
}
export async function getStaticProps(context) {
const slug = context.params.slug;
if (!slug) {
throw new Error('Article not found');
}
// 1
const previewMode = context.preview ? true : null;
// 2
let article;
if (previewMode) {
article = await fetchArticlePreviewApi(slug);
} else {
article = await fetchArticleApi(slug);
}
if (!article) {
return { notFound: true };
}
return {
props: {
article,
previewMode, // 3
}
};
}
export default ArticleView;
In the above code:
- When you view a page in preview mode, the
preview
property is set totrue
and added to thecontext
object, which is passed as a parameter to thegetStaticProps
function. Based on the value ofcontext.preview
, you decide the value ofpreviewMode
. - If
previewMode
istrue
, you fetch thearticle
associated with the requested slug inpreview
mode by calling thefetchArticlePreviewApi
function; otherwise, you fetch the article using thefetchArticleApi
function. - You return the
previewMode
as a prop along with thearticle
. -
previewMode
is then destructured in theArticleView
. - In the JSX template, if
previewMode
is set totrue
, you display a text stating that the preview mode is enabled and a button to close the preview mode. Clicking this button calls theexitPreviewMode
function. - The
exitPreviewMode
function is used to turn off the preview mode by calling the exit preview API endpoint, ie/api/exit-preview
, then reloads the current article page after receiving a response.
Accessing Preview Mode
Save your progress. Since you have added environment variables to your Next.js project, you need to restart your Next.js development server. Once the server restarts, visit http://localhost:3000/api/preview?secret=&slug= by replacing:
-
<secret>
with the preview secret defined in the.env
file -
<slug>
with the slug of a draft article
If you have passed the correct secret
and slug
query parameters, you will be redirected to http://localhost:3000/articles/:
If you want to know more about the cookies set by the preview mode, open your browser’s Developer Tools and open the Cookies section. You’ll notice the __prerender_bypass
and __next_preview_data
cookies set in your browser:
At this point, you can test the exit-preview
API endpoint as well. Click Turn Off Preview Mode at the top of the page to exit the preview mode. It will delete all the cookies and refresh the page. You’ll be redirected to the 404 - Not Found page:
Adding Preview Button in Strapi
Visiting the preview URL directly is not handy, especially when it involves complex slugs and long preview secrets. The best thing to do is to add a preview button in Strapi Content Manager.
To do so, you need to create a preview button component in React and inject it into Strapi Content Manager. This is done by creating a Strapi plugin.
At the root (backend
) of the Strapi project, generate a plugin using the following command:
npm run strapi generate
- Choose "plugin" from the list, press Enter, and give the plugin a name in kebab-case. Name it
preview-button
. - Choose
JavaScript
for the plugin language.
Make a PreviewLink
directory for your preview-button
plugin.
mkdir -p ./src/plugins/preview-button/admin/src/components/PreviewLink
Create an index.js
file in ./src/plugins/preview-button/admin/src/components/PreviewLink/
to provide a link for the Preview button.
// ./src/plugins/preview-button/admin/src/components/PreviewLink/index.js
import React from 'react';
import { useCMEditViewDataManager } from '@strapi/helper-plugin';
import Eye from '@strapi/icons/Eye';
import { LinkButton } from '@strapi/design-system/LinkButton';
const PreviewLink = () => {
const {initialData} = useCMEditViewDataManager();
if (!initialData.slug) {
return null;
}
return (
<LinkButton
size="S"
startIcon={<Eye/>}
style=
href={`${CLIENT_FRONTEND_URL}?secret=${CLIENT_PREVIEW_SECRET}&slug=${initialData.slug}`}
variant="secondary"
target="_blank"
rel="noopener noreferrer"
title="page preview"
>Preview
</LinkButton>
);
};
export default PreviewLink;
The above code creates a PreviewLink
React component. In the StyledPreviewLink
component, you have specified a href
attribute that navigates the user to the preview API endpoint on the Next.js website. It makes reference to CLIENT_FRONTEND_URL
and CLIENT_PREVIEW_SECRET
, so you need to add these variables as environment variables.
To do so, open the .env
file at the root of the Strapi project and add the following environment variables:
CLIENT_PREVIEW_SECRET=<preview-secret>
CLIENT_FRONTEND_URL=http://localhost:3000
Add the same preview secret that you added in your Next.js project.
Since these variables need to be accessed on Strapi’s admin frontend, you need to add a custom webpack configuration.
Rename ./src/admin/webpack.config.example.js to ./src/admin/webpack.config.js. Refer to the v4 code migration: Updating the webpack configuration from the Official Strapi v4 Documentation for more information.
Edit ./src/admin/webpack.config.js. Refer to the Official webpack docs for more information: DefinePlugin | webpack.
// ./src/admin/webpack.config.js
'use strict';
/* eslint-disable no-unused-vars */
module.exports = (config, webpack) => {
// Note: we provide webpack above so you should not `require` it
// Perform customizations to webpack config
// Important: return the modified config
config.plugins.push(
new webpack.DefinePlugin({
CLIENT_FRONTEND_URL: JSON.stringify(process.env.CLIENT_FRONTEND_URL),
CLIENT_PREVIEW_SECRET: JSON.stringify(process.env.CLIENT_PREVIEW_SECRET),
})
)
return config;
};
The above code injects the CLIENT_FRONTEND_URL
and CLIENT_PREVIEW_SECRET
as global variables so that they can be used anywhere in the Strapi admin frontend.
Finally, to inject the PreviewLink
component in the Strapi Content Manager, edit the index.js
file at ./src/plugins/preview-button/admin/src/index.js
with the following code:
// ./src/plugins/preview-button/admin/src/index.js
import { prefixPluginTranslations } from '@strapi/helper-plugin';
import pluginPkg from '../../package.json';
import pluginId from './pluginId';
import Initializer from './components/Initializer';
import PreviewLink from './components/PreviewLink';
import PluginIcon from './components/PluginIcon';
const name = pluginPkg.strapi.name;
export default {
register(app) {
app.addMenuLink({
to: `/plugins/${pluginId}`,
icon: PluginIcon,
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: name,
},
Component: async () => {
const component = await import(/* webpackChunkName: "[request]" */ './pages/App');
return component;
},
permissions: [
// Uncomment to set the permissions of the plugin here
// {
// action: '', // the action name should be plugin::plugin-name.actionType
// subject: null,
// },
],
});
app.registerPlugin({
id: pluginId,
initializer: Initializer,
isReady: false,
name,
});
},
bootstrap(app) {
app.injectContentManagerComponent('editView', 'right-links', {
name: 'preview-link',
Component: PreviewLink
});
},
async registerTrads({ locales }) {
const importedTrads = await Promise.all(
locales.map(locale => {
return import(
/* webpackChunkName: "translation-[request]" */ `./translations/${locale}.json`
)
.then(({ default: data }) => {
return {
data: prefixPluginTranslations(data, pluginId),
locale,
};
})
.catch(() => {
return {
data: {},
locale,
};
});
})
);
return Promise.resolve(importedTrads);
},
};
The above code injects the PreviewLink
component into the content manager.
Next enable the plugin by creating ./config/plugins.js
.
// ./config/plugins.js
module.exports = {
// ...
'Preview Button': {
enabled: true,
resolve: './src/plugins/preview-button' // path to plugin folder
},
// ...
}
All the above additions make changes to the Strapi admin frontend in React. You need to rebuild the admin panel.
To do so, shut down Strapi’s development server and rebuild the admin panel by running the following command:
npm run build
Once the rebuild is complete, start Strapi’s development server by running the following command:
npm run develop
Wait for the server to start and then visit localhost:1337/admin. Open any draft article. You’ll see a Preview button at the right-hand side of the content manager:
In case you’re not able to see the Preview button, delete the .cache
and build
directories by running rm -rf .cache build
, then rebuild your Strapi admin frontend by running npm run build
.
At this point, you can test whether the preview button works. Update the content of your draft article and click Save.
Click the Preview button. You’ll be directed to the single article page on your Next.js website.
Conclusion
Great work! One of the best things of Strapi is that it is open-source. Even if it doesn’t have a certain feature, you can customize Strapi according to your needs. This is exactly what you did today. Your content editors will love the preview mode because they can use it to view their content without building the entire website.
For a similar option, you can implement Previews in a Nuxt.js app using Strapi as a backend.
The entire source code for this tutorial is available in this GitHub repository.