Before I start, I have to make sure you are the right person to read what will follow.
This article will explain in details how you can create pages on the fly with Strapi using Dynamic Zones for a Next.js website. It is therefore intended for developers as it will be quite technical.
However, if you are a member of a marketing team and you are curious to learn more on the topic with less technical terms, I redirect you to an article which is about how our own marketing team uses Strapi.
Let's get started!
npx create-strapi-app api --quickstart
You can decide to enable the localization or not on some fields in your collection-types by editing them:
You can press save! Here are some explanation before going further:
title: Name of your page (localized)
I am ready to bet that the only difficulty you can have right now is about the famous block
Dynamic Zone. I'm going to quickly explain what it will do.
Easy definition: A Dynamic Zone is used for dynamically include components in your Content-Types.
When you include a component in a content-type, it will necessarily be included like your seo component. If you go to your content manager trying to create a new Page, you will see your seo component no matter what.
But what if you want to have a specific component on a page like an FAQ on your homepage but you don't want it on your pricing page? You simply use Dynamic Zone 😉
Here is an example of what you can have. This screenshot comes from our live demo FoodAdvisor that you can try for free.
The Dynamic Zone contains a list of components it can use for a specific content-types. You can then have the possibility to include one of these components in your content-types.
I guess it's time to create this list!
The category on the right will allow you to classify your components. I advise you to include all your block components in the "blocks" category. Concerning others components, you can create other categories as you want.
As you can see this component also contains components. Cool right? But maybe a little bit complicated to conceive. I advise you to include the header, buttons and link components in a shared
called category.
If you are having trouble creating these components in the admin, you can manually create them in your code editor:
./components/shared/link.json
1{
2 "collectionName": "components_shared_links",
3 "info": {
4 "name": "link",
5 "icon": "backward",
6 "description": ""
7 },
8 "options": {},
9 "attributes": {
10 "href": {
11 "type": "string",
12 "required": true
13 },
14 "label": {
15 "type": "string",
16 "required": true
17 },
18 "target": {
19 "type": "enumeration",
20 "enum": [
21 "_blank"
22 ]
23 },
24 "isExternal": {
25 "type": "boolean",
26 "default": false,
27 "required": false
28 }
29 }
30}
./components/shared/button.json
1{
2 "collectionName": "components_shared_buttons",
3 "info": {
4 "name": "button",
5 "icon": "compress",
6 "description": ""
7 },
8 "options": {},
9 "attributes": {
10 "theme": {
11 "type": "enumeration",
12 "enum": [
13 "primary",
14 "secondary",
15 "muted"
16 ],
17 "default": "primary",
18 "required": true
19 },
20 "link": {
21 "type": "component",
22 "repeatable": false,
23 "component": "shared.link"
24 }
25 }
26}
./components/shared/header.json
1{
2 "collectionName": "components_shared_headers",
3 "info": {
4 "name": "header",
5 "icon": "heading",
6 "description": ""
7 },
8 "options": {},
9 "attributes": {
10 "theme": {
11 "type": "enumeration",
12 "enum": [
13 "primary",
14 "secondary",
15 "muted"
16 ],
17 "default": "primary",
18 "required": true
19 },
20 "label": {
21 "type": "string",
22 "required": false
23 },
24 "title": {
25 "type": "string",
26 "required": true
27 }
28 }
29}
./components/blocks/hero.json
1{
2 "collectionName": "components_slices_heroes",
3 "info": {
4 "name": "hero",
5 "icon": "pizza-slice"
6 },
7 "options": {},
8 "attributes": {
9 "images": {
10 "collection": "file",
11 "via": "related",
12 "allowedTypes": [
13 "images",
14 "files",
15 "videos"
16 ],
17 "plugin": "upload",
18 "required": false,
19 "pluginOptions": {}
20 },
21 "header": {
22 "type": "component",
23 "repeatable": false,
24 "component": "shared.header"
25 },
26 "text": {
27 "type": "string"
28 },
29 "buttons": {
30 "type": "component",
31 "repeatable": true,
32 "component": "shared.button"
33 }
34 }
35}
Perfect! Now you should have this component in your Dynamic Zone!
Important: An homepage doesn't have a slug. It must contains an empty string. To do this, just write something inside the slug field like "hello" then remove it. It will contains an empty string instead of nothing.
As you can see, these screenshots are taken from the application of our live demo which is FoodAdvisor. Feel free to place your own text/images.
We have our three components: header, button and link. This allows you to not re-create these fields inside your blocks component.
find
action for the Page
collection-type in the USERS & PERMISSIONS PLUGIN
to be able to fetch them through the API.You should have your homepage data in JSON format! Perfect! We are done concerning the Strapi side of this tutorial. To summarize, you:
Page
collection-typePage
collection-typehero
component in this Dynamic ZoneNow it is time to create a Next.js application that will render these pages!
Let's get started!
npx create-next-app client
The first thing we want to do is to create some useful functions for our application.
./client/utils/index.js
file including the following functions:1// Get the url of the Strapi API based om the env variable or the default local one.
2export function getStrapiURL(path) {
3 return `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:1337"}${path}`;
4}
5
6// This function will get the url of your medias depending on where they are hosted
7export function getStrapiMedia(url) {
8 if (url == null) {
9 return null;
10 }
11 if (url.startsWith("http") || url.startsWith("//")) {
12 return url;
13 }
14 return `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:1337"}${url}`;
15}
16
17// handle the redirection to the homepage if the page we are browsinng doesn't exists
18export function redirectToHomepage() {
19 return {
20 redirect: {
21 destination: `/`,
22 permanent: false,
23 },
24 };
25}
26
27// This function will build the url to fetch on the Strapi API
28export function getData(slug, locale) {
29 const slugToReturn = `/${slug}?lang=${locale}`;
30 const apiUrl = `/pages?slug=${slug}&_locale=${locale}`;
31
32 return {
33 data: getStrapiURL(apiUrl),
34 slug: slugToReturn,
35 };
36}
./client/pages/services/api.js
containing the following code:1import delve from "dlv";
2
3// This functionn can merge required data but it is not used here.
4export async function checkRequiredData(block) {
5 return block;
6}
7
8// This function will get the data dependencies for every blocks.
9export async function getDataDependencies(json) {
10 let blocks = delve(json, "blocks", []);
11 blocks = await Promise.all(blocks.map(checkRequiredData));
12 return {
13 ...json,
14 blocks,
15 };
16}
./client/utils/localize.js
file containing the following code:1import delve from "dlv";
2
3// This function simply return the slug and the locale of the request with default values
4export function getLocalizedParams(query) {
5 const lang = delve(query, "lang");
6 const slug = delve(query, "slug");
7
8 return { slug: slug || "", locale: lang || "en" };
9}
Now everything should be ready to start on a solid basis.
./client/pages/[[...slug]].js
file containing the following code:1import delve from "dlv";
2
3import { getDataDependencies } from "./services/api";
4import { redirectToHomepage, getData } from "../utils";
5import { getLocalizedParams } from "../utils/localize";
6
7const Universals = ({ pageData }) => {
8 const blocks = delve(pageData, "blocks");
9 return <div></div>;
10};
11
12export async function getServerSideProps(context) {
13 const { slug, locale } = getLocalizedParams(context.query);
14
15 try {
16 const data = getData(slug, locale);
17 const res = await fetch(delve(data, "data"));
18 const json = await res.json();
19
20 if (!json.length) {
21 return redirectToHomepage();
22 }
23
24 const pageData = await getDataDependencies(delve(json, "0"));
25 console.log(pageData);
26 return {
27 props: { pageData },
28 };
29 } catch (error) {
30 return redirectToHomepage();
31 }
32}
33
34export default Universals;
Awesome! Now it is time to build the BlockManager! This component will simply tell your page to render this or this component based on witch components the Dynamic Zone includes.
./client/components/shared/BlockManager/index.js
file containing the following code:1import Hero from '../../blocks/Hero';
2
3const getBlockComponent = ({ __component, ...rest }, index) => {
4 let Block;
5
6 switch (__component) {
7 case 'blocks.hero':
8 Block = Hero;
9 break;
10 }
11
12 return Block ? <Block key={`index-${index}`} {...rest} /> : null;
13};
14
15const BlockManager = ({ blocks }) => {
16 return <div>{blocks.map(getBlockComponent)}</div>;
17};
18
19BlockManager.defaultProps = {
20 blocks: [],
21};
22
23export default BlockManager;
You can see that this component is simply looking at every components included in the Dynamic Zone. Since you only included the hero
one, we simply tell this file to render it. However we need to create the hero
component file in Next.js
./client/components/blocks/Hero/index.js
file containing the following code:1import delve from 'dlv';
2
3import ImageCards from './image-cards';
4import CustomLink from '../../shared/CustomLink';
5
6const Hero = ({ images, header, text, buttons }) => {
7 const title = delve(header, 'title');
8
9 return (
10 <section className="text-gray-600 body-font py-40 flex justify-center items-center 2xl:h-screen">
11 <div className="container flex md:flex-row flex-col items-center">
12 <div className="mt-4 relative relative-20 lg:mt-0 lg:col-start-1">
13 <ImageCards images={images} />
14 </div>
15
16 <div className="lg:flex-grow md:w-1/2 my-12 lg:pl-24 md:pl-16 md:mx-auto flex flex-col md:items-start md:text-left items-center text-center">
17 {title && (
18 <h1 className="title-font lg:text-6xl text-5xl mb-4 font-black text-gray-900">
19 {title}
20 </h1>
21 )}
22
23 {text && <p className="mb-8 px-2 leading-relaxed">{text}</p>}
24
25 <div className="block space-y-3 md:flex md:space-y-0 space-x-2">
26 {buttons &&
27 buttons.map((button, index) => (
28 <button
29 key={`heroButton-${index}`}
30 className={`inline-block text-${delve(
31 button,
32 'theme'
33 )}-text bg-${delve(
34 button,
35 'theme'
36 )} border-0 py-2 px-6 focus:outline-none hover:bg-${delve(
37 button,
38 'theme'
39 )}-darker rounded-full shadow-md hover:shadow-md text-lg`}
40 >
41 <CustomLink {...delve(button, 'link')} />
42 </button>
43 ))}
44 </div>
45 </div>
46 </div>
47 </section>
48 );
49};
50
51Hero.defaultProps = {};
52
53export default Hero;
This component simply display the Hero component from your Dynamic Zone. It requires two other components to work.
./client/components/blocks/Hero/image-cards.js
file containing the following code:1import delve from 'dlv';
2
3import { getStrapiMedia } from '../../../utils';
4
5const ImageCards = ({ images }) => {
6 return (
7 <div className="relative space-y-4">
8 <div className="flex items-end justify-center lg:justify-start space-x-4">
9 {images &&
10 images
11 .slice(0, 2)
12 .map((image, index) => (
13 <img
14 className="rounded-lg shadow-lg w-32 md:w-56"
15 key={`heroImage-${index}`}
16 width="200"
17 src={getStrapiMedia(delve(image, 'url'))}
18 alt={delve(image, 'alternativeText')}
19 />
20 ))}
21 </div>
22 <div className="flex items-start justify-center lg:justify-start space-x-4 md:ml-12">
23 {images &&
24 images
25 .slice(2, 4)
26 .map((image, index) => (
27 <img
28 className="rounded-lg shadow-lg w-32 md:w-56"
29 key={`heroImage-${index}`}
30 width="200"
31 src={getStrapiMedia(delve(image, 'url'))}
32 alt={delve(image, 'alternativeText')}
33 />
34 ))}
35 </div>
36 </div>
37 );
38};
39
40ImageCards.defaultProps = {};
41
42export default ImageCards;
This will simply display the images. You'll need to have 4 images for your hero components to be correctly displayed.
./client/components/shared/CustomLink/index.js
file containing the following code:1import Link from 'next/link';
2
3const CustomLink = ({ label, href, locale, target, isExternal }) => {
4 if (isExternal) {
5 return (
6 <Link href={href}>
7 <a target={target}>{label}</a>
8 </Link>
9 );
10 } else {
11 return (
12 <Link href={`${href}?lang=${locale || 'en'}`}>
13 <a target={target}>{label}</a>
14 </Link>
15 );
16 }
17};
18
19CustomLink.defaultProps = {};
20
21export default CustomLink;
This component allows to handle link that are internal or external which is pretty useful!
Wait a second! This looks horrible! Can you tell what is missing here?
Tailwind CSS of course! It is a CSS framework that will allow you to rapidly build modern websites with a beautiful UI.
yarn add tailwindcss@latest postcss@latest autoprefixer@latest
./client/tailwind.config.js
file containing the following code:1module.exports = {
2 // mode: 'jit',
3 purge: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
4 darkMode: false, // or 'media' or 'class'
5 theme: {
6 extend: {
7 colors: {
8 primary: {
9 DEFAULT: '#e27d60',
10 light: '#e48a6f',
11 darker: '#cb7056',
12 text: '#FFFFFF',
13 lightest: '#f0beaf',
14 },
15 secondary: {
16 DEFAULT: '#41b3a3',
17 light: '#85dcb',
18 darker: '#3aa192',
19 text: '#FFFFFF',
20 lightest: '#ecf7f5',
21 },
22 muted: {
23 DEFAULT: '#E5E7EB',
24 ligth: '#F3F4F6',
25 darker: '#D1D5DB',
26 text: '#555b66',
27 },
28 },
29 },
30 },
31 variants: {
32 extend: {
33 // ...
34 ringWidth: ['hover', 'active'],
35 },
36 },
37 plugins: [],
38};
This is the default theme we use for FoodAdvisor, feel free to customize it!
./client/postcss.config.js
file containing the following code:1// If you want to use other PostCSS plugins, see the following:
2// https://tailwindcss.com/docs/using-with-preprocessors
3module.exports = {
4 plugins: {
5 tailwindcss: {},
6 autoprefixer: {},
7 },
8}
./client/pages/_app.js
to include your tailwind theme:1// Add this line
2import "tailwindcss/tailwind.css";
3
4function MyApp({ Component, pageProps }) {
5 return <Component {...pageProps} />;
6}
7
8export default MyApp;
You should be good!
Great! Now let's add another components to our Dynamic Zone so that we can display it on our website!
CtaCommandLine
component in your Dynamic Zone containing these fields:
Now you simply need to create the Next.js component for this CtaCommandLine and to include it in your BlockManager.
./client/components/blocks/CtaCommandLine/index.js
file containing the following:1import { CopyBlock, nord } from 'react-code-blocks';
2
3const CtaCommandLine = ({ title, text, theme, commandLine }) => {
4 return (
5 <div className={`bg-${theme}`}>
6 <div className="text-center w-full mx-auto py-12 px-4 sm:px-6 lg:py-16 lg:px-8 z-20">
7 <h2 className={`text-3xl font-extrabold text-black sm:text-4xl`}>
8 {title && <span className="block">{title}</span>}
9 {text && <span className={`block text-white`}>{text}</span>}
10 </h2>
11 <div className="py-12 lg:flex-shrink-0 flex items-center justify-center">
12 <div className="block md:w-2/5 w-full shadow-2xl text-center">
13 <CopyBlock
14 text={commandLine}
15 language="bash"
16 codeBlock
17 theme={nord}
18 showLineNumbers={false}
19 />
20 </div>
21 </div>
22 </div>
23 </div>
24 );
25};
26
27CtaCommandLine.defaultProps = {};
28
29export default CtaCommandLine;
This component requires the react-code-blocks
package.
yarn add react-code-blocks
./client/components/shared/BlockManager/index.js
file to look like this:1import Hero from "../../blocks/Hero";
2import CtaCommandLine from "../../blocks/CtaCommandLine";
3
4const getBlockComponent = ({ __component, ...rest }, index) => {
5 let Block;
6
7 switch (__component) {
8 case "blocks.hero":
9 Block = Hero;
10 break;
11 case "blocks.cta-command-line":
12 Block = CtaCommandLine;
13 break;
14 }
15
16 return Block ? <Block key={`index-${index}`} {...rest} /> : null;
17};
18
19const BlockManager = ({ blocks }) => {
20 return <div>{blocks.map(getBlockComponent)}</div>;
21};
22
23BlockManager.defaultProps = {
24 blocks: [],
25};
26
27export default BlockManager;
You are simply importing your new component so that your Block Manager can display it!
Well that is pretty much it! I hope that you understand the power of the Dynamic Zone feature now! The process here to create sections of a page is very simple and fast to do. In fact, you need to:
Strapi Side
Next.js Side
./clients/components/blocks/Hero/index.js
)[[...slug]].js
)So the goal for you, when it comes to create a whole website with this architecture, is to clearly define with your marketing team every components you want to be able to display on any pages. You simply define their fields in Strapi, you create their frontend component in Next.js and after that, your marketing team will have the flexibility and freedom to create the content.
It sounds like a conclusion but this tutorial is not over! Let's create another page to prove that you can actually create pages on the fly! Let's build a pricing page!
pricing
components part of the blocks
category to add to our Dynamic Zone that looks like this:This one can be a little bit tricky if you are not that familiar with components. But this one contains the header component that you already have.
perks
component part of a pricing
categorypricingCards
component part of a pricing
categoryAdd a component
in your Dynamic Zone and create your pricing
component by adding the header
and the pricingCards
(repeatable) components.You are doing great! Keep up the good work!
pricing
page containing the following fields for nowAgain, feel free to use different images or text. This is just an example.
pricing
component to your page:As you saw, the pricingCards
and the perks
components are repeatable which means that you can add an infinite number of them. The first pricing card is the free one that contains its title, description, price and perks. Again, as the perks is repeatable, you can have a lot of them!
Each perk contains a name and a boolean if this is included in the pricing plan.
Nothing else to do in the admin! Let's dive in Next.js!
./client/components/blocks/Pricing/index.js
file containing the following code:1const Pricing = ({ header, pricingCards }) => {
2 return (
3 <div className="bg-white pb-60">
4 <div className="text-center pt-24">
5 {header && (
6 <h2
7 className={`text-${header.theme} font-extrabold tracking-wide uppercase`}
8 >
9 {header.label}
10 </h2>
11 )}
12
13 {header && (
14 <p className="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 dark:text-white sm:text-4xl">
15 {header.title}
16 </p>
17 )}
18 </div>
19
20 <div className="sm:flex flex-wrap justify-center items-center text-center gap-8 pb-12 pt-16 mt-4">
21 {pricingCards &&
22 pricingCards.map((card, index) => (
23 <div
24 className="shadow-lg rounded-2xl w-64 bg-white dark:bg-gray-800 p-4"
25 key={`pricingCard-${index}`}
26 >
27 <p className="text-gray-800 dark:text-gray-50 text-xl font-medium mb-4">
28 {card.title}
29 </p>
30 <p className="text-gray-900 dark:text-white text-3xl font-bold">
31 ${card.price}
32 <span className="text-gray-300 text-sm">/ month</span>
33 </p>
34 <p className="text-gray-600 dark:text-gray-100 text-xs mt-4">
35 {card.description}
36 </p>
37 <ul className="text-sm text-gray-600 dark:text-gray-100 w-full mt-6 mb-6">
38 {card.perks &&
39 card.perks.map((perk, index) => (
40 <li
41 className="mb-3 flex items-center"
42 key={`perk-${index}`}
43 >
44 {perk.included ? (
45 <svg
46 className="h-6 w-6 mr-2"
47 xmlns="http://www.w3.org/2000/svg"
48 width="6"
49 height="6"
50 stroke="currentColor"
51 fill="#10b981"
52 viewBox="0 0 1792 1792"
53 >
54 <path d="M1412 734q0-28-18-46l-91-90q-19-19-45-19t-45 19l-408 407-226-226q-19-19-45-19t-45 19l-91 90q-18 18-18 46 0 27 18 45l362 362q19 19 45 19 27 0 46-19l543-543q18-18 18-45zm252 162q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"></path>
55 </svg>
56 ) : (
57 <svg
58 xmlns="http://www.w3.org/2000/svg"
59 width="6"
60 height="6"
61 className="h-6 w-6 mr-2"
62 fill="red"
63 viewBox="0 0 1792 1792"
64 >
65 <path d="M1277 1122q0-26-19-45l-181-181 181-181q19-19 19-45 0-27-19-46l-90-90q-19-19-46-19-26 0-45 19l-181 181-181-181q-19-19-45-19-27 0-46 19l-90 90q-19 19-19 46 0 26 19 45l181 181-181 181q-19 19-19 45 0 27 19 46l90 90q19 19 46 19 26 0 45-19l181-181 181 181q19 19 45 19 27 0 46-19l90-90q19-19 19-46zm387-226q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"></path>
66 </svg>
67 )}
68 {perk.name}
69 </li>
70 ))}
71 </ul>
72 <button
73 type="button"
74 className="py-2 px-4 bg-secondary hover:bg-secondary-darker text-white w-full transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg "
75 >
76 Choose plan
77 </button>
78 </div>
79 ))}
80 </div>
81 </div>
82 );
83};
84
85Pricing.defaultProps = {};
86
87export default Pricing;
./client/components/shared/BlockManager/index.js
file with the following code:1import Hero from "../../blocks/Hero";
2import Pricing from "../../blocks/Pricing";
3import CtaCommandLine from "../../blocks/CtaCommandLine";
4
5const getBlockComponent = ({ __component, ...rest }, index) => {
6 let Block;
7
8 switch (__component) {
9 case "blocks.hero":
10 Block = Hero;
11 break;
12 case "blocks.pricing":
13 Block = Pricing;
14 break;
15 case "blocks.cta-command-line":
16 Block = CtaCommandLine;
17 break;
18 }
19
20 return Block ? <Block key={`index-${index}`} {...rest} /> : null;
21};
22
23const BlockManager = ({ blocks }) => {
24 return <div>{blocks.map(getBlockComponent)}</div>;
25};
26
27BlockManager.defaultProps = {
28 blocks: [],
29};
30
31export default BlockManager;
http://localhost:3000/pricing
Awesome isn't it! Well that's it for this tutorial! I showed you a very simple and quick way to create pages on the fly with Strapi and Next.js. We can totally improve this application by implementing i18n and previews.
This would complexify this tutorial, but I can write a second part if you are interested! In that case, let me know by starting a discussion on our forum!
See you in the next article!
Maxime started to code in 2015 and quickly joined the Growth team of Strapi. He particularly likes to create useful content for the awesome Strapi community. Send him a meme on Twitter to make his day: @MaxCastres