The Strapi Preview feature allows you to preview your frontend application directly from Strapi's admin panel. This makes content management a whole lot smarter.
If you've been struggling to provide a preview experience for your team, you're in for a treat. The new Strapi 5 release brings the preview functionality that changes how you manage and visualize content across multiple platforms.
Let's dive into the nuts and bolts of this powerful feature and explore how it can transform your content management process.
The complete video for this tutorial is available here.
In this deep dive, Rémi from the Strapi engineering team reveals how the new Strapi 5 Preview feature enables real-time and multi-platform content workflows right inside the Strapi admin panel.
The complete code for this tutorial can be found in this project repo.
Also, you can clone the strapiconf
branch of the LaunchPad repo to get the complete code.
For this tutorial, ensure you have Strapi LaunchPad installed and running on your local machine.
See this tutorial on how to set up LaunchPad locally.
The Strapi preview is a single iframe that puts your frontend inside Strapi. An HTML iframe is used to display a web page within a web page.
To set up a preview in Strapi, we first need to know the URL to render on the iframe. To get this URL, we need to provide a function in the Strapi config file that takes the entry the user is looking for. So when a user is looking at the Article content type, they get to see a Blog URL. See the image below.
In the image above, the preview URL is the result of the function of the entry you provide. So if you are viewing the Article content type, the preview URL should be a Blog URL.
Similar to the image above, the logic function for a basic preview is usually in this form:
1// Path: ./config/admin.js
2
3switch (model) {
4 case "api::page.page":
5 if (slug === "homepage") {
6 return "/";
7 }
8 return `/${slug}`;
9 case "api::article.article":
10 return `/blog/${slug}`;
11 default:
12 return null;
13}
The Strapi Preview feature architecture looks like this.
In the image above, which shows the Strapi preview architecture, the blue box represents Strapi which renders the iframe in green. The config is what provides the URL or source of the iframe.
So far, the basic preview setup is great, however, there is a problem. The problem is that it is limited! What happens when you want to preview some content or draft that is not live yet?
The solution will be that you need to be able to preview it directly on your Strapi admin without affecting the live production version that your clients are going to use. For this reason, we need to set up the preview mode in the config.
Let's set up preview mode to allow you to preview drafts that are not live yet without affecting your Strapi production version.
In order to continue with setting up the preview mode and other sections of this tutorial we will be using LaunchPad, the official Strapi demo app.
👋 NOTE: You can learn how to set up LaunchPad locally and also deploy to Strapi cloud using this blog post.
Navigate to the config file ./config/admin.js
and update the code inside with the following code:
1// Path: ./config/admin.js
2
3const getPreviewPathname = (model, { locale, document }): string | null => {
4 const { slug } = document;
5 const prefix = `/${locale ?? "en"}`;
6
7 switch (model) {
8 case "api::page.page":
9 if (slug === "homepage") {
10 return prefix;
11 }
12 return `${prefix}/${slug}`;
13 case "api::article.article":
14 return `${prefix}/blog/${slug}`;
15 case "api::product.product":
16 return `${prefix}/products/${slug}`;
17 case "api::product-page.product-page":
18 return `${prefix}/products`;
19 case "api::blog-page.blog-page":
20 return `${prefix}/blog`;
21 default:
22 return null;
23 }
24};
25
26export default ({ env }) => {
27 const clientUrl = env("CLIENT_URL");
28
29 return {
30 auth: {
31 secret: env("ADMIN_JWT_SECRET"),
32 },
33 apiToken: {
34 salt: env("API_TOKEN_SALT"),
35 },
36 transfer: {
37 token: {
38 salt: env("TRANSFER_TOKEN_SALT"),
39 },
40 },
41 flags: {
42 nps: env.bool("FLAG_NPS", true),
43 promoteEE: env.bool("FLAG_PROMOTE_EE", true),
44 },
45 preview: {
46 enabled: true,
47 config: {
48 allowedOrigins: [clientUrl, "'self'"],
49 async handler(model, { documentId, locale, status }) {
50 const document = await strapi.documents(model).findOne({
51 documentId,
52 fields: ["slug"],
53 });
54
55 const pathname = getPreviewPathname(model, { locale, document });
56
57 // Disable preview if the pathname is not found
58 if (!pathname) {
59 return null;
60 }
61
62 const urlSearchParams = new URLSearchParams({
63 secret: env("PREVIEW_SECRET"),
64 pathname,
65 status,
66 documentId,
67 clientUrl,
68 });
69
70 return `${clientUrl}/api/preview?${urlSearchParams}`;
71 },
72 },
73 },
74 };
75};
Here is what we did above:
getPreviewPathname
function holds to the switch logic we mentioned earlier that computes the URL of the preview. So this gives us the pathname
.urlSearchParams
object. ${clientUrl}/api/preview?${urlSearchParams}
that is a Next.js application. clientSecret
as a search parameter, the Next.js application will perform authentication to make sure that it is a Strapi admin user making the request.status
to see if it is a draft which sets a cookie that says Strapi in draft mode. It will then redirect to the actual pathname
, read the cookies to see that it is in draft mode, and adjust the content it is going to fetch.The code above is nothing new. This is how Preview has worked for quite some time. In summary, instead of returning the actual preview frontend, we are adding an API endpoint in between.
However, what happens when your company grows and you have many websites that want to consume the same content or even a native mobile application? In this case, it won't be one entry equals one URL.
This shows that our setup has its limit. Now let's create another layer for multi-frontend preview in Strapi.
If you want to consume the same content across multiple websites or native applications you will need a multi-frontend preview setup. This is a proxy preview concept. Take a look at the image below:
In the image above, Strapi represents the blue box. Instead of directly rendering your Next.js apps or your native apps which represent the orange boxes, you can have a single frontend that is dedicated to hosting the preview.
This single frontend is known as the proxy, which is represented by the green box above. The proxy will render multiple types of previews. The types of previews could be simultaneous previews, setting up tabs, or any kind of preview depending on your choice.
Let's set up a multi-frontend proxy page that will allow rendering multiple types of previews.
The proxy we will create will act as an intermediary, allowing you to switch between different preview types.
Since we are already in the Strapi dashboard, we can use the Strapi API to create routes in Strapi instead of creating an external React application or another frontend which might make the setup a complex task.
Head over to the config file we updated recently and modify the redirecting client URL with the following code:
1// Path: ./config/admin.ts
2
3...
4
5return `/admin/preview-proxy?${urlSearchParams}`;
6
7...
In the code above the /admin
route is because we are working inside Strapi admin, and the nonexistent route is preview-proxy
.
When you click the "Open preview" button, you will see that we now embedded Strapi admin within itself.
Let's create the preview-proxy
non-existent route. The way to create an admin route inside of Strapi is to head over to ./src/admin
create a file called app.tsx
and add the following code.
1// Path: ./src/admin/app.tsx
2
3import type { StrapiApp } from "@strapi/strapi/admin";
4import { lazy } from "react";
5
6const PreviewProxy = lazy(() => import("./PreviewProxy"));
7
8export default {
9 config: {
10 locales: ["en"],
11 },
12 register(app: StrapiApp) {
13 app.router.addRoute({
14 path: "preview-proxy",
15 element: <PreviewProxy />,
16 });
17 },
18};
With the new router API app.router
, we created a route preview-proxy
which renders the PreviewProxy React component. Let's create the PreviewProxy component.
Head over to ./strapi/src/admin/
and add the following code. The code below will render a box for the preview.
1// Path: LaunchPad/strapi/src/admin/PreviewProxy.tsx
2
3// ... imports
4
5const PreviewProxy = () => {
6 // ... app variables
7
8 return (
9 <Portal>
10 <Box
11 position="fixed"
12 top="0"
13 left="0"
14 right="0"
15 bottom="0"
16 background="neutral100"
17 zIndex={4}
18 ></Box>
19 </Portal>
20 );
21};
22
23export default PreviewProxy;
The code above uses the Strapi Design system. position=fixed
is used because we don't want to have two navigation menus within the app. You can choose to change the background to any color of your choice. For this tutorial, we will use neutral100
.
Recall that we want to be able to render for the web and native app. So, let's build this using Select.
Now that we have a proxy page for multiple-frontend, let's add a select logic to switch between web and native mobile apps.
We will be using Expo for our React Native application.
Add a device selector logic in the ./src/admin/PreviewProxy.tsx
file:
1// Path: LaunchPad/strapi/src/admin/PreviewProxy.tsx
2
3// ... imports
4
5const PreviewProxy = () => {
6 // ... app variables
7
8 return (
9 <Portal>
10 <Box
11 // ... Box styling
12 >
13 // Selector Logic
14 <Flex gap={4} justifyContent="center" padding={2}>
15 <Typography>Preview on:</Typography>
16 <SingleSelect value={selectedDevice.id} onChange={handleDeviceChange}>
17 {devices.map((device) => (
18 <SingleSelectOption key={device.id} value={device.id}>
19 {device.name}
20 </SingleSelectOption>
21 ))}
22 </SingleSelect>
23 </Flex>
24 </Box>
25 </Portal>
26 );
27};
28
29export default PreviewProxy;
This is what you should see.
We can now switch between web and mobile apps.
The next goal is to render the actual preview below the selection.
We will toggle between web and mobile.
This is the code for it.
1// Path: LaunchPad/strapi/src/admin/PreviewProxy.tsx
2
3// ... imports
4
5const PreviewProxy = () => {
6 // ... app variables
7
8 return (
9 <Portal>
10 <Box
11 // ... Box styling
12 >
13 // Selector Logic
14 <Flex gap={4} justifyContent="center" padding={2}>
15 <Typography>Preview on:</Typography>
16 <SingleSelect value={selectedDevice.id} onChange={handleDeviceChange}>
17 {devices.map((device) => (
18 <SingleSelectOption key={device.id} value={device.id}>
19 {device.name}
20 </SingleSelectOption>
21 ))}
22 </SingleSelect>
23 </Flex>
24
25 // Toggle Between Expo React Native and Web
26 {isMobileApp ? (
27 <ExpoPreview />
28 ) : (
29 <Box
30 tag="iframe"
31 src={previewURL}
32 width={selectedDevice.width}
33 height={selectedDevice.height}
34 />
35 )}
36 </Box>
37 </Portal>
38 );
39};
40
41export default PreviewProxy;
We can now toggle between the web and the native app. For the web devices, we computed the height and width programmatically.
Let's create the ExpoPreview component.
Create a component for previewing the Expo QR code.
1// Path: LaunchPad/strapi/src/admin/utils/ExpoPreview.tsx
2
3import * as React from "react";
4import { Flex } from "@strapi/design-system";
5
6export const ExpoPreview = () => {
7 const qrCodeSrc = React.useMemo(() => {
8 const qrCodeUrl = new URL("https://qr.expo.dev/eas-update");
9 qrCodeUrl.searchParams.append(
10 "projectId",
11 "4327bdd6-9794-49d7-9b95-6a5198afd339",
12 );
13 qrCodeUrl.searchParams.append("runtimeVersion", "1.0.0");
14 qrCodeUrl.searchParams.append("channel", "default");
15 return qrCodeUrl.toString();
16 }, []);
17
18 return (
19 <Flex
20 display="flex"
21 alignItems="center"
22 justifyContent="center"
23 height="100%"
24 width="100%"
25 >
26 <img src={qrCodeSrc} alt="Expo QR Code" />
27 </Flex>
28 );
29};
The ExpoPreview component above displays a QR code for you to scan and use the EAS service to preview on your device.
You will need to create a security configuration for content security policy as shown in the code below the QR code for the Strapi:
1// Path: LaunchPad/strapi/config/middlewares.ts
2export default [
3 "strapi::logger",
4 "strapi::errors",
5 {
6 name: "strapi::security",
7 config: {
8 contentSecurityPolicy: {
9 useDefaults: true,
10 directives: {
11 "connect-src": ["'self'", "https:"],
12 "img-src": [
13 "'self'",
14 "data:",
15 "blob:",
16 "market-assets.strapi.io",
17 "qr.expo.dev",
18 ],
19 "media-src": [
20 "'self'",
21 "data:",
22 "blob:",
23 "market-assets.strapi.io",
24 "qr.expo.dev",
25 ],
26 upgradeInsecureRequests: null,
27 },
28 },
29 },
30 },
31 "strapi::cors",
32 "strapi::poweredBy",
33 "strapi::query",
34 "strapi::body",
35 "strapi::session",
36 "strapi::favicon",
37 "strapi::public",
38 "global::deepPopulate",
39];
Head over to your Next.js app and locate the LaunchPad/next/app/api/preview/route.ts
file to handle the preview for the web.
1// Path: LaunchPad/next/app/api/preview/route.ts
2
3import { draftMode } from "next/headers";
4import { redirect } from "next/navigation";
5
6export const GET = async (request: Request) => {
7 // Parse query string parameters
8 const { searchParams } = new URL(request.url);
9 const secret = searchParams.get("secret");
10 const pathname = searchParams.get("pathname") ?? "/";
11 const status = searchParams.get("status");
12
13 // Check the secret and next parameters
14 // This secret should only be known to this route handler and the CMS
15 if (secret !== process.env.PREVIEW_SECRET) {
16 return new Response("Invalid token", { status: 401 });
17 }
18
19 if (status === "published") {
20 // Make sure draft mode is disabled so we only query published content
21 draftMode().disable();
22 } else {
23 // Enable draft mode so we can query draft content
24 draftMode().enable();
25 }
26
27 redirect(pathname);
28};
This is what we should now see.
We can now see the QR code of our Expo app. Of course, we are not setting up emulators or using EAS but to demonstrate how to preview our content on multiple frontends, and even on a native mobile app.
There is currently a problem with our preview. When we make an update, it is no longer going to be reflected.
This is because the content update works by Strapi (the blue box) dispatching an event to the window of the iframe. So the proxy (the green box) receives the event but the event no longer reaches the previews (the orange boxes).
To achieve real-time updates, you need to implement event listeners in your proxy component. These listeners will catch update events dispatched by Strapi and forward them to the appropriate preview iframe.
1// Path: LaunchPad/strapi/src/admin/PreviewProxy.tsx
2
3// ... imports
4
5const PreviewProxy = () => {
6 // ... component variables
7
8 // handle real-time changes
9 const iframe = React.useRef<HTMLFrameElement>(null);
10 React.useEffect(() => {
11 const handleMessage = (message) => {
12 if (message.data.type === "strapiUpdate") {
13 iframe.current?.contentWindow.postMessage(message.data, clientURL);
14 }
15 };
16 window.addEventListener("message", handleMessage);
17 return () => window.removeEventListener("message", handleMessage);
18 }, []);
19
20 return (
21 <Portal>
22 // ... other codes
23
24 // Attach ref to iframe
25 <Box
26 tag="iframe"
27 src={previewURL}
28 width={selectedDevice.width}
29 height={selectedDevice.height}
30 marginLeft="auto"
31 marginRight="auto"
32 display="block"
33 borderWidth={0}
34 ref={iframe}
35 />
36 </Portal>
37 );
38};
In the code above, we create a useEffect
hook function to set up a listener to listen to the message from Strapi. We set up a ref to an iframe so we can dispatch an event on it.
The sky's the limit when it comes to custom features. For example, you could implement a change highlighting feature that visually indicates which parts of the content have been updated. This can be incredibly useful for content editors working on large documents.
1// Highlighter Hook
2export function useUpdateHighlighter() {
3 const [searchParams] = useSearchParams();
4 const { kind, model, documentId, locale } = Object.fromEntries(searchParams);
5
6 const previousDocument = React.useRef<any>(undefined);
7 const iframe = React.useRef<HTMLIFrameElement>(null);
8
9 const { refetch } = useDocument({
10 collectionType:
11 kind === "collectionType" ? "collection-types" : "single-types",
12 model,
13 documentId,
14 params: { locale },
15 });
16
17 React.useEffect(() => {
18 const handleMessage = async (message: any) => {
19 if (message.data.type === "strapiUpdate") {
20 const response: any = await refetch();
21 const document = response.data.data;
22
23 let changedFields: Array<string> = [];
24 if (document != null && previousDocument.current !== document) {
25 // Get the diff of the previous and current document, find the path of changed fields
26 changedFields = Object.keys(document).filter(
27 (key) => document[key] !== previousDocument.current?.[key],
28 );
29 }
30
31 iframe.current?.contentWindow?.postMessage(
32 { ...message.data, changedFields },
33 new URL(iframe.current.src).origin,
34 );
35
36 previousDocument.current = document;
37 }
38 };
39
40 // Add the event listener
41 window.addEventListener("message", handleMessage);
42
43 // Cleanup the event listener on unmount
44 return () => {
45 window.removeEventListener("message", handleMessage);
46 };
47 }, [refetch]);
48
49 return { iframe };
50}
51
52
53// iframe inside PreviewProxy Component
54const { iframe } = useUpdateHighlighter();
The Strapi Preview Feature offers several key benefits that make it a standout solution for content management workflows:
The complete code for this tutorial can be found in this project repo.
Also, you can clone the strapiconf
branch of the LaunchPad repo to get the complete code.
In conclusion, the Strapi 5 Preview Feature represents a significant leap forward in content management capabilities. By providing a flexible, customizable, and powerful preview system, Strapi CMS can provide content teams to work more efficiently and effectively.
Whether you're managing a simple blog or a complex multi-platform content ecosystem, the Strapi Preview Feature offers the tools you need to deliver outstanding content experiences.
Theodore is a Technical Writer and a full-stack software developer. He loves writing technical articles, building solutions, and sharing his expertise.
As part of the expansion team, Rémi's role is to help building and structuring the Strapi ecosystem, through projects like starters, plugins or the Strapi Market.