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!
Strapi
- Create a Strapi project using an SQLite3 database by running the following command:
npx create-strapi-app api --quickstart
- Create your admin user and let's meet in the Content-Types Builder!
- The first thing you want to do is to create a collection-type with localization enabled that will represent a page and contains these fields.
- title (text, short)
- slug (text, short)
- seo component:
- metaTitle (text, short)
- metaDescription (text, long)
- meta repeatable component:
- name (text, short)
- content (text, short)
- preventIndexing (boolean)
- structuredData (JSON)
- metaImage (single media)
- block (dynamic zone)
- publish_at (datetime)
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)
slug: Slug of your page (not localized)
seo component: Contains all your meta tags for SEO.
block: Dynamic zone that will allow you to dynamically include components on your page.
publish_at: Field that will allow you to schedule the publication of a page.
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!
- Go back to your Content-Types Builder. On the Page collection-type, click on Add a component for your block Dynamic Zone
- Click on "Create a new component" and fill the modal like this:
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.
- Create the fields of this "hero" components to look like the screenshot below:
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
{
"collectionName": "components_shared_links",
"info": {
"name": "link",
"icon": "backward",
"description": ""
},
"options": {},
"attributes": {
"href": {
"type": "string",
"required": true
},
"label": {
"type": "string",
"required": true
},
"target": {
"type": "enumeration",
"enum": [
"_blank"
]
},
"isExternal": {
"type": "boolean",
"default": false,
"required": false
}
}
}
./components/shared/button.json
{
"collectionName": "components_shared_buttons",
"info": {
"name": "button",
"icon": "compress",
"description": ""
},
"options": {},
"attributes": {
"theme": {
"type": "enumeration",
"enum": [
"primary",
"secondary",
"muted"
],
"default": "primary",
"required": true
},
"link": {
"type": "component",
"repeatable": false,
"component": "shared.link"
}
}
}
./components/shared/header.json
{
"collectionName": "components_shared_headers",
"info": {
"name": "header",
"icon": "heading",
"description": ""
},
"options": {},
"attributes": {
"theme": {
"type": "enumeration",
"enum": [
"primary",
"secondary",
"muted"
],
"default": "primary",
"required": true
},
"label": {
"type": "string",
"required": false
},
"title": {
"type": "string",
"required": true
}
}
}
./components/blocks/hero.json
{
"collectionName": "components_slices_heroes",
"info": {
"name": "hero",
"icon": "pizza-slice"
},
"options": {},
"attributes": {
"images": {
"collection": "file",
"via": "related",
"allowedTypes": [
"images",
"files",
"videos"
],
"plugin": "upload",
"required": false,
"pluginOptions": {}
},
"header": {
"type": "component",
"repeatable": false,
"component": "shared.header"
},
"text": {
"type": "string"
},
"buttons": {
"type": "component",
"repeatable": true,
"component": "shared.button"
}
}
}
Perfect! Now you should have this component in your Dynamic Zone!
- Press save! Now you can create an homepage!
- Go in the Content Manager and create a new page like the one below:
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.
- You can create another localized version of this page if you activated another locale in your application, other than that, press save!
- Be sure that you activated the
find
action for thePage
collection-type in theUSERS & PERMISSIONS PLUGIN
to be able to fetch them through the API. - Browse this url: http://localhost:1337/pages?slug=
You should have your homepage data in JSON format! Perfect! We are done concerning the Strapi side of this tutorial. To summarize, you:
- Created a
Page
collection-type - Created a Dynamic Zone for the
Page
collection-type - Included a
hero
component in this Dynamic Zone
Now it is time to create a Next.js application that will render these pages!
Next.js
Let's get started!
- Create a Next.js application by running the following command:
npx create-next-app client
The first thing we want to do is to create some useful functions for our application.
- Create a
./client/utils/index.js
file including the following functions:
// Get the url of the Strapi API based om the env variable or the default local one.
export function getStrapiURL(path) {
return `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:1337"}${path}`;
}
// This function will get the url of your medias depending on where they are hosted
export function getStrapiMedia(url) {
if (url == null) {
return null;
}
if (url.startsWith("http") || url.startsWith("//")) {
return url;
}
return `${process.env.NEXT_PUBLIC_API_URL || "http://localhost:1337"}${url}`;
}
// handle the redirection to the homepage if the page we are browsinng doesn't exists
export function redirectToHomepage() {
return {
redirect: {
destination: `/`,
permanent: false,
},
};
}
// This function will build the url to fetch on the Strapi API
export function getData(slug, locale) {
const slugToReturn = `/${slug}?lang=${locale}`;
const apiUrl = `/pages?slug=${slug}&_locale=${locale}`;
return {
data: getStrapiURL(apiUrl),
slug: slugToReturn,
};
}
- Create a
./client/pages/services/api.js
containing the following code:
import delve from "dlv";
// This functionn can merge required data but it is not used here.
export async function checkRequiredData(block) {
return block;
}
// This function will get the data dependencies for every blocks.
export async function getDataDependencies(json) {
let blocks = delve(json, "blocks", []);
blocks = await Promise.all(blocks.map(checkRequiredData));
return {
...json,
blocks,
};
}
- Create a
./client/utils/localize.js
file containing the following code:
import delve from "dlv";
// This function simply return the slug and the locale of the request with default values
export function getLocalizedParams(query) {
const lang = delve(query, "lang");
const slug = delve(query, "slug");
return { slug: slug || "", locale: lang || "en" };
}
Now everything should be ready to start on a solid basis.
- Create a
./client/pages/[[...slug]].js
file containing the following code:
import delve from "dlv";
import { getDataDependencies } from "./services/api";
import { redirectToHomepage, getData } from "../utils";
import { getLocalizedParams } from "../utils/localize";
const Universals = ({ pageData }) => {
const blocks = delve(pageData, "blocks");
return <div></div>;
};
export async function getServerSideProps(context) {
const { slug, locale } = getLocalizedParams(context.query);
try {
const data = getData(slug, locale);
const res = await fetch(delve(data, "data"));
const json = await res.json();
if (!json.length) {
return redirectToHomepage();
}
const pageData = await getDataDependencies(delve(json, "0"));
console.log(pageData);
return {
props: { pageData },
};
} catch (error) {
return redirectToHomepage();
}
}
export default Universals;
- Refresh your application in the browser and give a look at your Next.js logs in your terminal. You should see something like this:
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.
- Create a
./client/components/shared/BlockManager/index.js
file containing the following code:
import Hero from '../../blocks/Hero';
const getBlockComponent = ({ __component, ...rest }, index) => {
let Block;
switch (__component) {
case 'blocks.hero':
Block = Hero;
break;
}
return Block ? <Block key={`index-${index}`} {...rest} /> : null;
};
const BlockManager = ({ blocks }) => {
return <div>{blocks.map(getBlockComponent)}</div>;
};
BlockManager.defaultProps = {
blocks: [],
};
export 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
- Create
./client/components/blocks/Hero/index.js
file containing the following code:
import delve from 'dlv';
import ImageCards from './image-cards';
import CustomLink from '../../shared/CustomLink';
const Hero = ({ images, header, text, buttons }) => {
const title = delve(header, 'title');
return (
<section className="text-gray-600 body-font py-40 flex justify-center items-center 2xl:h-screen">
<div className="container flex md:flex-row flex-col items-center">
<div className="mt-4 relative relative-20 lg:mt-0 lg:col-start-1">
<ImageCards images={images} />
</div>
<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">
{title && (
<h1 className="title-font lg:text-6xl text-5xl mb-4 font-black text-gray-900">
{title}
</h1>
)}
{text && <p className="mb-8 px-2 leading-relaxed">{text}</p>}
<div className="block space-y-3 md:flex md:space-y-0 space-x-2">
{buttons &&
buttons.map((button, index) => (
<button
key={`heroButton-${index}`}
className={`inline-block text-${delve(
button,
'theme'
)}-text bg-${delve(
button,
'theme'
)} border-0 py-2 px-6 focus:outline-none hover:bg-${delve(
button,
'theme'
)}-darker rounded-full shadow-md hover:shadow-md text-lg`}
>
<CustomLink {...delve(button, 'link')} />
</button>
))}
</div>
</div>
</div>
</section>
);
};
Hero.defaultProps = {};
export default Hero;
This component simply display the Hero component from your Dynamic Zone. It requires two other components to work.
- Create a
./client/components/blocks/Hero/image-cards.js
file containing the following code:
import delve from 'dlv';
import { getStrapiMedia } from '../../../utils';
const ImageCards = ({ images }) => {
return (
<div className="relative space-y-4">
<div className="flex items-end justify-center lg:justify-start space-x-4">
{images &&
images
.slice(0, 2)
.map((image, index) => (
<img
className="rounded-lg shadow-lg w-32 md:w-56"
key={`heroImage-${index}`}
width="200"
src={getStrapiMedia(delve(image, 'url'))}
alt={delve(image, 'alternativeText')}
/>
))}
</div>
<div className="flex items-start justify-center lg:justify-start space-x-4 md:ml-12">
{images &&
images
.slice(2, 4)
.map((image, index) => (
<img
className="rounded-lg shadow-lg w-32 md:w-56"
key={`heroImage-${index}`}
width="200"
src={getStrapiMedia(delve(image, 'url'))}
alt={delve(image, 'alternativeText')}
/>
))}
</div>
</div>
);
};
ImageCards.defaultProps = {};
export default ImageCards;
This will simply display the images. You'll need to have 4 images for your hero components to be correctly displayed.
- Create
./client/components/shared/CustomLink/index.js
file containing the following code:
import Link from 'next/link';
const CustomLink = ({ label, href, locale, target, isExternal }) => {
if (isExternal) {
return (
<Link href={href}>
<a target={target}>{label}</a>
</Link>
);
} else {
return (
<Link href={`${href}?lang=${locale || 'en'}`}>
<a target={target}>{label}</a>
</Link>
);
}
};
CustomLink.defaultProps = {};
export default CustomLink;
This component allows to handle link that are internal or external which is pretty useful!
- Refresh your browser and see the results! Tada!
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.
- Execute the following command to install Tailwind CSS on your Next.js application:
yarn add tailwindcss@latest postcss@latest autoprefixer@latest
- Create a
./client/tailwind.config.js
file containing the following code:
module.exports = {
// mode: 'jit',
purge: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#e27d60',
light: '#e48a6f',
darker: '#cb7056',
text: '#FFFFFF',
lightest: '#f0beaf',
},
secondary: {
DEFAULT: '#41b3a3',
light: '#85dcb',
darker: '#3aa192',
text: '#FFFFFF',
lightest: '#ecf7f5',
},
muted: {
DEFAULT: '#E5E7EB',
ligth: '#F3F4F6',
darker: '#D1D5DB',
text: '#555b66',
},
},
},
},
variants: {
extend: {
// ...
ringWidth: ['hover', 'active'],
},
},
plugins: [],
};
This is the default theme we use for FoodAdvisor, feel free to customize it!
- Create a
./client/postcss.config.js
file containing the following code:
// If you want to use other PostCSS plugins, see the following:
// https://tailwindcss.com/docs/using-with-preprocessors
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
- Update your
./client/pages/_app.js
to include your tailwind theme:
// Add this line
import "tailwindcss/tailwind.css";
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />;
}
export default MyApp;
You should be good!
- Refresh your app to see the result!
Great! Now let's add another components to our Dynamic Zone so that we can display it on our website!
- Create a new
CtaCommandLine
component in your Dynamic Zone containing these fields:
- Create the content of this CTA in the Content Manager of your homepage:
Now you simply need to create the Next.js component for this CtaCommandLine and to include it in your BlockManager.
- Create a
./client/components/blocks/CtaCommandLine/index.js
file containing the following:
import { CopyBlock, nord } from 'react-code-blocks';
const CtaCommandLine = ({ title, text, theme, commandLine }) => {
return (
<div className={`bg-${theme}`}>
<div className="text-center w-full mx-auto py-12 px-4 sm:px-6 lg:py-16 lg:px-8 z-20">
<h2 className={`text-3xl font-extrabold text-black sm:text-4xl`}>
{title && <span className="block">{title}</span>}
{text && <span className={`block text-white`}>{text}</span>}
</h2>
<div className="py-12 lg:flex-shrink-0 flex items-center justify-center">
<div className="block md:w-2/5 w-full shadow-2xl text-center">
<CopyBlock
text={commandLine}
language="bash"
codeBlock
theme={nord}
showLineNumbers={false}
/>
</div>
</div>
</div>
</div>
);
};
CtaCommandLine.defaultProps = {};
export default CtaCommandLine;
This component requires the react-code-blocks
package.
- Stop your server and install the package with the following command:
yarn add react-code-blocks
- Start your server again!
- Update your
./client/components/shared/BlockManager/index.js
file to look like this:
import Hero from "../../blocks/Hero";
import CtaCommandLine from "../../blocks/CtaCommandLine";
const getBlockComponent = ({ __component, ...rest }, index) => {
let Block;
switch (__component) {
case "blocks.hero":
Block = Hero;
break;
case "blocks.cta-command-line":
Block = CtaCommandLine;
break;
}
return Block ? <Block key={`index-${index}`} {...rest} /> : null;
};
const BlockManager = ({ blocks }) => {
return <div>{blocks.map(getBlockComponent)}</div>;
};
BlockManager.defaultProps = {
blocks: [],
};
export default BlockManager;
You are simply importing your new component so that your Block Manager can display it!
- Refresh your app and see the result!
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
- Create a Dynamic Zone
- Include components in your Dynamic Zone (Hero, CTA).
- Create the data for these components in the Content Manager of the page (Homepage)
Next.js Side
- Create the component of the component (Hero →
./clients/components/blocks/Hero/index.js
) - Include this component in the BlockManager component.
- Include the BlockManager component in your Next.js page (
[[...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!
- First, let's create a
pricing
components part of theblocks
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.
- Create a
perks
component part of apricing
category- name (short text)
- included (boolean)
- Create a
pricingCards
component part of apricing
category- title (short text)
- description (long text)
- price (number int)
- perks repeatable component
- Then you can click on
Add a component
in your Dynamic Zone and create yourpricing
component by adding theheader
and thepricingCards
(repeatable) components.
You are doing great! Keep up the good work!
- Create a
pricing
page containing the following fields for now
Again, feel free to use different images or text. This is just an example.
- Now you can add your new
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!
- Create a
./client/components/blocks/Pricing/index.js
file containing the following code:
const Pricing = ({ header, pricingCards }) => {
return (
<div className="bg-white pb-60">
<div className="text-center pt-24">
{header && (
<h2
className={`text-${header.theme} font-extrabold tracking-wide uppercase`}
>
{header.label}
</h2>
)}
{header && (
<p className="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 dark:text-white sm:text-4xl">
{header.title}
</p>
)}
</div>
<div className="sm:flex flex-wrap justify-center items-center text-center gap-8 pb-12 pt-16 mt-4">
{pricingCards &&
pricingCards.map((card, index) => (
<div
className="shadow-lg rounded-2xl w-64 bg-white dark:bg-gray-800 p-4"
key={`pricingCard-${index}`}
>
<p className="text-gray-800 dark:text-gray-50 text-xl font-medium mb-4">
{card.title}
</p>
<p className="text-gray-900 dark:text-white text-3xl font-bold">
${card.price}
<span className="text-gray-300 text-sm">/ month</span>
</p>
<p className="text-gray-600 dark:text-gray-100 text-xs mt-4">
{card.description}
</p>
<ul className="text-sm text-gray-600 dark:text-gray-100 w-full mt-6 mb-6">
{card.perks &&
card.perks.map((perk, index) => (
<li
className="mb-3 flex items-center"
key={`perk-${index}`}
>
{perk.included ? (
<svg
className="h-6 w-6 mr-2"
xmlns="http://www.w3.org/2000/svg"
width="6"
height="6"
stroke="currentColor"
fill="#10b981"
viewBox="0 0 1792 1792"
>
<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>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="6"
height="6"
className="h-6 w-6 mr-2"
fill="red"
viewBox="0 0 1792 1792"
>
<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>
</svg>
)}
{perk.name}
</li>
))}
</ul>
<button
type="button"
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 "
>
Choose plan
</button>
</div>
))}
</div>
</div>
);
};
Pricing.defaultProps = {};
export default Pricing;
- Now include this component in your
./client/components/shared/BlockManager/index.js
file with the following code:
import Hero from "../../blocks/Hero";
import Pricing from "../../blocks/Pricing";
import CtaCommandLine from "../../blocks/CtaCommandLine";
const getBlockComponent = ({ __component, ...rest }, index) => {
let Block;
switch (__component) {
case "blocks.hero":
Block = Hero;
break;
case "blocks.pricing":
Block = Pricing;
break;
case "blocks.cta-command-line":
Block = CtaCommandLine;
break;
}
return Block ? <Block key={`index-${index}`} {...rest} /> : null;
};
const BlockManager = ({ blocks }) => {
return <div>{blocks.map(getBlockComponent)}</div>;
};
BlockManager.defaultProps = {
blocks: [],
};
export default BlockManager;
- Go to
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!