Author: Mark Munyaka
In this guide, you will learn about the importance of previews in Jamstack. I will also walk you through the process of adding the previews mode to a blog built with Next.js and Strapi headless CMS for a better content editing experience.
The Preview Mode
One of the major features of the Jamstack web development architecture is its core principle of pre-rendering, i.e., generating the website's content during build time instead of every request time. Pre-rendering improves the speed, reduces the complexity, and reduces the cost of hosting the website.
The pre-rendering process in the Jamstack web development methodology is achieved with the help of a static site generator(SSG). An SSG collects content from a headless CMS, where the files are created and stored, with the help of an API.
The contents are then converted into static files (prebuilt HTML, CSS, and Javascript files) by the SSG during the SSG build time. Finally, the generated static files can be deployed to a production server or stored in a content delivery network(CDN) and served as a whole to the end-user in every request.
Even though it is very beneficial, this pre-rendering process tends not to be ideal during the content creation process. Content creators and editors need to render a preview of their draft at every request time instead of waiting for the complete website build process before viewing their changes.
The easiest solution to the problem conferred by most static site generators is setting up a Preview Mode that will bypass the static site generator pre-rendering process. With the preview mode activated, content editors can view their changes on every page request instead of waiting for the complete build process to run.
Setting up the Preview Mode
For the sake of this tutorial, we will create a simple blog website for which we will later activate the previews mode to allow for a better editing experience.
Prerequisites
To follow through with the guide, you will need a basic knowledge of:
Next.js: Next.js is our static site generator of choice for this tutorial. Next.js is an open-source react framework used for building production-grade react websites. It comes with a preview API that we will use in this guide.
Tailwind CSS: Tailwind CSS is a utility-first CSS framework. It enables you to add CSS directly to your HTML for easier development and scaling.
Strapi v4: Strapi is the leading open-source headless CMS used for managing content efficiently and designing secure production-ready Restful or GraphQL APIs.
Markdown-to-jsx: We will use the Markdown-to-jsx npm package for converting the markdown files returned from Strapi into jsx to be rendered by Next.js.
This tutorial assumes you have the following system configuration:
- Operating System: Unix/Linux, macOS or Windows
- Version Control: Git
- Shell: Any Unix Shell or Git Bash for Windows
- Text Editor: Any (I used VSCode)
- Node: v12.xx to v16.xx
- Package Manager: npm or yarn (npm comes with Node)
Strapi Setup
The first step in this guide will be to create the backend of our blog. We will be using the strapi blog template as a starter for faster and easier development.
npx create-strapi-app backend --quickstart --template @strapi/template-blog@1.0.0 blog
This command will create a new backend folder with the Strapi backend fully set up and all dependencies installed during installation.
After the successful installation, you will automatically be redirected to your browser, where you will be prompted to register before using Strapi. If not redirected, visit http://localhost:1337/admin in your browser.
Add your details and click LET’S START to access the Strapi dashboard. Navigate to the Content Manager. Select Article under COLLECTION TYPES, and you will see a list of articles.
The blog template for Strapi comes with some preconfigured schema for blogging apps, making our development easier and faster.
Set the STATE
of the articles in COLLECTION TYPES to either Published
or Draft
. First, click on the pen symbol next to Published.
Click Unpublish. Choose Yes, confirm in the Confirmation dialog box, then Save to change the state of the article.
Repeat the procedure for the articles you want to make drafts and your list of articles should be similar to this:
Next.js Setup
The next step in this guide will be the frontend setup with Next.js. First, initialize your frontend project with this command.
npx create-next-app frontend
A frontend folder is created in your root directory. Navigate into the folder and run npm run dev
to start the frontend server. You can view your frontend locally using http://localhost:3000.
Tailwind CSS Setup
Stop your frontend server by pressing Ctrl
plus C
on your keyboard. Install Tailwind CSS into your frontend with npm.
npm install -D tailwindcss@latest postcss@latest autoprefixer@latest
Then, generate tailwind CSS configuration files.
npx tailwindcss init -p
This command will generate a postcss.config.js
and a tailwind.config.js
file to the root directory of your project. We will use these two files for any configurations relating to tailwind CSS in our project.
Navigate to the pages/_app.js
file and replace import '../styles/globals.css'
with import 'tailwindcss/tailwind.css'
. You can delete the styles
folder since we will not be using any custom CSS for our blog.
Next, add the paths to all of your template files in your tailwind.config.js
file.
/* ./tailwind.config.js */
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
Complete your Tailwind CSS setup by adding the @tailwind
directives for each of Tailwind’s layers to your ./styles/globals.css
file.
/* ./styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
For more information on installing Tailwind CSS, refer to the Install Tailwind CSS with Next.js guide.
Markdown-to-jsx Installation
Install the markdown-to-jsx package into your frontend using the following command:
npm install markdown-to-jsx
The markdown-to-jsx npm package will be used to convert the markdown files obtained from Strapi into JSX files to be rendered in our frontend framework.
Connecting Frontend to Backend
To connect our Next.js frontend app to our backend, we will create a helper function that will make API calls to the Strapi backend with the fetch API.
To create the helper function, create a lib
folder in your frontend root folder, and add a file named api.js
to the lib
folder.
Add the following code lines to the api.js file:
/* ./lib/api.js */
export function getStrapiURL(path = "") {
return `${process.env.NEXT_PUBLIC_STRAPI_API_URL || "http://localhost:1337/api"
}${path}`;
}
// Helper to make GET requests to Strapi
export async function fetchAPI(path) {
const requestUrl = getStrapiURL(path);
const response = await fetch(requestUrl);
const data = await response.json();
return data;
}
This code sends a GET
request to Strapi URL with the fetch API. Since we are only building a blog, a helper to fetch the content using GET
requests is enough for our project.
Designing the Blog Layout
Now that we have our frontend and backend connected and Tailwind CSS fully installed, the next step will be to create a layout for our blog.
Firstly, create a components
folder in the root folder and add a layout.js
file into the folder. Next, add the following code into the ./components/layout.js
file:
/* ./components/layout.js */
import Header from './header';
import Footer from './footer';
const Layout = ({ children }) => (
<>
<Header />
{children}
<Footer />
</>
);
export default Layout;
Then, we will create our header and footer files. First, add a header.js
and a footer.js
file to the components
folder.
Add the following code to your ./components/header.js
file:
/* ./components/header.js */
import React from "react";
import Link from "next/link";
const Header = () => {
return (
<div>
<nav className="flex justify-center items-center h-16 py-2 mb-2 bg-gray-100 w-full border-b">
<div>
<Link href="/">
<a href="/" className="px-2 font-black lg:px-0 text-lg">
Sybox Digital Blog
</a>
</Link>
</div>
</nav>
</div>
);
};
export default Header;
Then, add the following code to the ./components/footer.js
file.
/* ./components/footer.js */
import React from "react";
const Footer = () => {
return (
<footer className="border-t mt-10 pt-12 pb-32 px-4 lg:px-0">
<div className="px-4 pt-3 pb-4 border-b -mx-4 border-gray-100">
<div className="max-w-xl mx-auto">
<h2 className="text-xl text-left inline-block font-semibold text-gray-800">Subscribe to Our Newsletter</h2>
<div className="text-gray-700 text-xs pl-px">
Latest news, articles and monthly updates delivered to your inbox.
</div>
<form action="#" className="mt-2">
<div className="flex items-center">
<input type="email" className="w-full py-4 mr-2 bg-gray-100 shadow-inner rounded-md border border-gray-400 focus:outline-none" required />
<button className="bg-blue-600 text-gray-200 text-sm rounded">Sign Up</button>
</div>
</form>
</div>
</div>
</footer>
);
};
export default Footer;
The layout component will be wrapped around our page code to give a uniform header and footer across our blog.
Designing the Homepage
Move to your ./pages/index.js
file and discard the pre-existing code there. Then add the following lines of code to the ./pages/index.js
file:
/* ./pages/index.js */
import Link from "next/link";
import Layout from "../components/layout";
import { fetchAPI } from "../lib/api";
export default function Home({ articles }) {
return (
<>
<Layout>
<body className="antialiased md:bg-gray-100">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{articles.data.map((article) => (
<div key={article.id} className="md:p-8 p-2 bg-white">
<div>
{article.attributes.image && (
<img src={`http://localhost:1337${article.attributes.image.data.attributes.url}`} />
)}
</div>
{article.attributes.title}
<div>
{article.attributes.category && (
<div className="text-indigo-500 font-semibold text-base mt-2">
{article.attributes.category.data.attributes.name}
</div>
)}
</div>
<h1 className="font-semibold text-gray-900 leading-none text-xl mt-1 capitalize truncate">
{article.attributes.title}
</h1>
<Link href={`/article/${article.attributes.slug}`}>
<div className="max-w-full">
<div className="text-base font-medium tracking-wide text-gray-600 mt-1">
{article.attributes.description}
</div>
</div>
</Link>
<div className="flex items-center space-x-2 mt-20">
<div>
{article.attributes.author && (
<div className="text-gray-900 font-semibold pb-2">
{article.attributes.author.data.attributes.name}
</div>
)}
<div className="text-gray-500 font-semibold text-sm">
Created on - {new Date(article.attributes.createdAt).toDateString()}
</div>
<div className="text-gray-500 font-semibold text-sm">
Updated on - {new Date(article.attributes.updatedAt).toDateString()}
</div>
</div>
</div>
</div>
))}
</div>
</body>
</Layout>
</>
);
}
export async function getStaticProps() {
try {
const articles = await fetchAPI("/articles?populate=*");;
return {
props: {
articles
},
};
} catch (error) {
return { error };
}
}
In this code, we exported articles with the published status from the Strapi backend with getStaticProps()
. The getStaticProps()
is a Next.js API that will pre-render our page at build time using the props returned in it.
We then pass the props(articles)
returned by getStaticProps()
into the <Home />
component. The props are then rendered in a UI built with Tailwind CSS. We will also wrap the page in the <Layout />
component to access our pre-made header and footer components.
Start your frontend server:
npm run dev`
The homepage on http://localhost:3000 should be similar to this:
Designing the Blog Post Page
We will design a dynamic route to render each blog post page based on their slug in this step. Thus, the URL of each page will be dependent on the slug of the page.
Stop your server if you haven’t already. Create an article
folder in the pages
folder, and add a [slug].js
file to the ./pages/article
folder.
Add the following lines of code to the ./pages/article/[slug].js
file:
/* ./pages/article/[slug].js */
import Markdown from "markdown-to-jsx";
import { fetchAPI } from "../../lib/api";
import Layout from "../../components/layout";
const Article = ({ article }) => {
return (
<>
<Layout>
<div className="mt-10">
<div className="mb-4 md:mb-0 w-full max-w-screen-md mx-auto">
<div className="absolute left-0 bottom-0 w-full h-full" />
<div>
{article.data[0].attributes.image && (
<img src={`http://localhost:1337${article.data[0].attributes.image.data.attributes.url}`} />
)}
</div>
<div>
{article.data[0].attributes.category && (
<a
href="#"
className="px-4 py-1 bg-black text-blue-200 inline-flex text-md items-center justify-center mb-2"
>
{article.data[0].attributes.category.data.attributes.name}
</a>
)}
</div>
<h2 className="text-4xl pt-2 font-semibold text-gray-500 leading-tigh`t">
{article.data[0].attributes.description}
</h2>
<div className="mt-3">
{article.data[0].attributes.author && (
<p className="text-blue-900 font-semibold pb-2">
Written by - {article.data[0].attributes.author.data.attributes.name}
</p>
)}
</div>
</div>
<article className="prose lg:prose-xl px-4 lg:px-0 mt-12 text-gray-700 max-w-screen-md mx-auto text-lg leading-relaxed">
<Markdown>{article.data[0].attributes.content}</Markdown>
</article>
</div>
</Layout>
</>
);
};
export default Article;
export async function getStaticPaths() {
const articles = await fetchAPI("/articles?populate=*");
return {
paths: articles.data.map((article) => ({
params: {
slug: article.attributes.slug,
},
})),
fallback: false,
};
}
export async function getStaticProps({ params }) {
const article = await fetchAPI(`/articles/?filters\[slug\][$eq]=${params.slug}&populate=*`);
return {
props: { article },
revalidate: 1,
};
}
This gets a single article with its slug and displays the article. So, first, we query our backend for every article with the getStaticPaths()
API. Then, we get the slug and store them in the slug params
.
The slug params
are then passed into the getStaticProps()
API. The getStaticProps()
function queries the backend with a single slug and returns the data collected from the backend as props. The props data are then passed into the < Article />
component and rendered with our UI created with tailwind CSS.
Start your frontend server:
npm run dev
Visit one of the published articles by entering Sample Published Articles in your browser. Replace ${slug}
with a slug to one of the posts, for example http://localhost:3000/article/what-s-inside-a-black-hole.
Each blog post should appear similar to this:
Now that our blog is completely designed, we can now configure the preview API for a more effortless content editing experience on our platform.
Activating the Preview Mode
The first step will be to create a preview secret. Then, we will use this secret to access the preview mode securely in our browser. Create an .env file in the root of your frontend folder and add your preview secret to the file.
PREVIEW_SECRET=****
Replace ****** with a unique string of characters.
Next, we will add a helper function to our ./lib/api.js
file. We will use this helper to query our backend for preview data.
/* ./lib/api.js
*
* Add this code below the last line of code
*/
export async function getPageData(slug, preview = false) {
// Find the pages that match this slug
const pagesData = await fetchAPI(
`/articles?publicationState=preview&filters\[slug\][$eq]=${slug}&populate=*`
);
// Make sure we found something, otherwise return null
if (pagesData == null || pagesData.length === 0) {
return null;
}
// Return the first item since there should only be one result per slug
return pagesData;
}
The code queries the Strapi for the requested slug and returns either published or draft blog posts.
The next step will be to create and configure the preview API route. In ./pages/api
, add a new file called preview.js
. Then, add the following code to the file:
/* ./pages/api/preview.js */
import { getPageData } from "../../lib/api"
export default async function handler(req, res) {
if (req.query.secret !== (process.env.PREVIEW_SECRET || 'secret-token')) {
return res.status(401).json({ message: "Invalid token" });
}
const pageData = await getPageData(req.query.slug, true);
if (!pageData) {
return res.status(401).json({ message: "Invalid slug" });
}
res.setPreviewData({});
res.writeHead(307, { Location: `/article/${req.query.slug}` });
res.end();
};
This code matches the secret in the preview URL to the preview secret in the .env
file. After verifying the preview request, the API then activates the preview mode and redirects to the article location.
When the preview mode is activated, Next.js sets some cookies in your browser. We will add these cookies to any request going to Next.js automatically, and Next.js treats any request with this cookie as a preview mode request.
Go back to your [slug].js
file, import the getPageData helper from the ./lib/api
page:
import { fetchAPI, getPageData } from "../../lib/api";
Change your getStaticProps()
code as follows:
/* ./pages/article/[slug].js
*
* Change getStaticProps()
*/
export async function getStaticProps({ params, preview = null }) {
const article = await getPageData(params.slug, preview);
return {
props: { article, preview },
revalidate: 1,
};
}
Accessing the Preview Mode
Access the preview mode by navigating to this url http://localhost:3000/api/preview?secret=&slug=
Where:
= secret token defined in your .env
file
= the draft page slug.
Deactivating the Previews Mode
Navigate to the ./pages/api/
folder and create a new file named exit-preview.js
. Next, add the following lines of code to the exit-preview.js
file:
/*. /pages/api/exit-preview.js */
export default function handler(req, res) {
try {
// Clears the preview mode cookies.
// This function accepts no arguments.
res.clearPreviewData()
return res.status(200).json({ message: 'Cookies Cleared' })
} catch (error) {
return res.status(500).json({ message: error })
}
}
This code clears all the cookies set by the previews mode API in your browser. Also, navigate to ./pages/articles/[slug].js
and change the <Article />
component.
/* ./pages/articles/[slug].js */
import Markdown from "markdown-to-jsx";
import { fetchAPI, getPageData } from "../../lib/api";
import Layout from "../../components/layout";
const Article = ({ article, preview }) => {
return (
<>
/* Section of code to add */
/* Beginning of Section */
<div>
{preview ? (
<div className="relative bg-indigo-600">
<div className="max-w-7xl mx-auto py-3 px-3 sm:px-6 lg:px-8">
<div className="pr-16 sm:text-center sm:px-16">
<p className="font-medium text-white">
<span>Preview mode is on,</span>
<span className="block sm:ml-2 sm:inline-block">
<a
href="http://localhost:3000/api/exit-preview"
className="underline hover:text-cyan transition-colors"
>
turn off
</a>
</span>
</p>
</div>
</div>
</div>
) : null}
</div>
/* End of section */
<Layout>
<div className="mt-10">
<div className="mb-4 md:mb-0 w-full max-w-screen-md mx-auto">
<div className="absolute left-0 bottom-0 w-full h-full" />
<h1 className="text-6xl font-bold pb-4 text-center">{article.data[0].attributes.title}</h1>
<div>
{article.data[0].attributes.image && (
<img src={`http://localhost:1337${article.data[0].attributes.image.data.attributes.url}`} />
)}
</div>
<div>
{article.data[0].attributes.category && (
<a
href="#"
className="px-4 py-1 bg-black text-blue-200 inline-flex text-md items-center justify-center mb-2"
>
{article.data[0].attributes.category.data.attributes.name}
</a>
)}
</div>
<h2 className="text-4xl pt-2 font-semibold text-gray-500 leading-tight">
{article.data[0].attributes.description}
</h2>
<div className="mt-3">
{article.data[0].attributes.author && (
<p className="text-blue-900 font-semibold pb-2">
Written by - {article.data[0].attributes.author.data.attributes.name}
</p>
)}
</div>
</div>
<article className="prose lg:prose-xl px-4 lg:px-0 mt-12 text-gray-700 max-w-screen-md mx-auto text-lg leading-relaxed">
<Markdown>{article.data[0].attributes.content}</Markdown>
</article>
</div>
</Layout>
</>
);
};
export default Article;
export async function getStaticPaths() {
const articles = await fetchAPI("/articles?populate=*");
return {
paths: articles.data.map((article) => ({
params: {
slug: article.attributes.slug,
},
})),
fallback: false,
};
}
export async function getStaticProps({ params, preview = null }) {
// const article = await fetchAPI(`/articles/?filters\[slug\][$eq]=${params.slug}&populate=*`);
const article = await getPageData(params.slug, preview);
return {
props: { article, preview },
revalidate: 1,
};
}
The blog post pages should look like this now when the preview mode is activated.
Click turn off to deactivate the preview mode. You can also deactivate the preview mode by navigating to http://localhost:3000/api/exit-preview
.
Conclusion
The preview mode is an essential static site generator tool that can improve the content editor experience when using the Jamstack architecture. To learn more about Next.js previews, check out the Next.js Preview Mode documentation.