Simply copy and paste the following command line in your terminal to create your first Strapi project.
npx create-strapi-app
my-project --quickstart
Strapi is the leading open-source headless CMS. You can use it with the front-end framework of your choice, and in the Strapi blog, you can find a list of tutorials that will guide you on how to get started with:
In this tutorial, you will learn to create a blog website using Strapi for the back-end and Next.js for the front-end. Next.js is an open-source, flexible React framework that gives you building blocks to create fast web applications.
Would you like to see what the final result will look like before getting started? Check out the starter and have a sneak peek of what the final result of this tutorial will look like.
First, you will need to create a blog-strapi folder:
mkdir blog-strapi && cd blog-strapi
Now, you will get started with the easiest part of this tutorial, using a series of Strapi templates that you can use for some different use cases.
These templates are Strapi applications that contain pre-built collection-types and single-types suited for the appropriate use case and data type. In this tutorial, you'll use the Blog template and connect a Next.js application to it.
# Using yarn
yarn create strapi-app backend --quickstart --template @strapi/template-blog@1.0.0 blog
# Using npm
npx create-strapi-app backend --quickstart --template @strapi/template-blog@1.0.0 blog
Don't forget that Strapi is running on http://localhost:1337.
That's it! You have created your Strapi project. I'm not kidding! Now, you can create your Next.js application to fetch our content from Strapi.
Ok, wait! Let's talk about this amazing template you just created.
You should know that before we had starters and templates we only had tutorials.
The idea of creating starters came to us when we realized that we could do something with the end result of our tutorials. Thus our starters were born. However, Strapi evolves quickly, very quickly. At the time, starters were made up of a repository that included the back-end and the front-end.
This meant that updating the Strapi version on all our starters took too much time. Therefore we decided to develop templates that are always created with the latest versions of Strapi. This was achieved simply by passing the repository parameter to the desired template like you just did. This method also gives a recommended architecture for your Strapi project.
These templates provide a solid basis for your Strapi application and include the following:
Feel free to modify any of this if you want. However, for this tutorial, this initial setup should be enough.
Nice! Now that Strapi is ready, you will create your Next.js application.
Well, the easiest part has been completed; let's get our hands dirty developing our blog!
1. Next setup
frontend
app by running the following command:npx create-next-app frontend
Once the installation is finished, add the following packages which you’ll need later: moment
, react-moment
, and react-markdown
. These packages allow you to display the publication date in a nice format and the content of articles in Markdown.
You will also need qs
package. A querystring parsing and stringifying library with some added security for easily fetch the data in Strapi.
frontend
folder:yarn add qs moment react-moment react-markdown
2. Fetch data from Strapi
First, you must create a function to fetch data from your Strapi API.
./frontend/lib/api.js
file containing the following code:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import qs from "qs";
/**
* Get full Strapi URL from path
* @param {string} path Path of the URL
* @returns {string} Full Strapi URL
*/
export function getStrapiURL(path = "") {
return `${
process.env.NEXT_PUBLIC_STRAPI_API_URL || "http://localhost:1337"
}${path}`;
}
/**
* Helper to make GET requests to Strapi API endpoints
* @param {string} path Path of the API route
* @param {Object} urlParamsObject URL params object, will be stringified
* @param {Object} options Options passed to fetch
* @returns Parsed API call response
*/
export async function fetchAPI(path, urlParamsObject = {}, options = {}) {
// Merge default and user options
const mergedOptions = {
headers: {
"Content-Type": "application/json",
},
...options,
};
// Build request URL
const queryString = qs.stringify(urlParamsObject);
const requestUrl = `${getStrapiURL(
`/api${path}${queryString ? `?${queryString}` : ""}`
)}`;
// Trigger API call
const response = await fetch(requestUrl, mergedOptions);
// Handle response
if (!response.ok) {
console.error(response.statusText);
throw new Error(`An error occured please try again`);
}
const data = await response.json();
return data;
}
You can see two functions here:
fetStrapiURL
: By default, this function will look at a NEXT_PUBLIC_STRAPI_API_URL
env variable in your .env
file. If you don't have one, it will use the default url of Strapi on your machine.
fetchAPI
: This function gets the request URL thanks to the getStrapiURL function above.
Then it calls the fetch function on this requestUrl with some parameters that are stringifyed and returns the data in a JSON format.
It would help if you also created another function outside of this file to fetch Strapi media.
./frontend/lib/media.js
file containing the following:1
2
3
4
5
6
7
import { getStrapiURL } from "./api";
export function getStrapiMedia(media) {
const { url } = media.data.attributes;
const imageUrl = url.startsWith("/") ? getStrapiURL(url) : url;
return imageUrl;
}
This function returns the correct URL of an image depending on where it is hosted (either on your local machine or hosted on a server).
Locally, an image has a URL structure like so: /uploads/… Whereas on Cloudinary it has a URL structure like this: https://cloudinary.com/....
Also, let's add the following code in your next.config.js
file to access a remote image but still use the built-in Next.js Image Optimization API.
1
2
3
4
5
6
7
module.exports = {
reactStrictMode: true,
images: {
loader: "default",
domains: ["localhost"],
},
};
Your application can fetch content from your Strapi API and fetch Strapi media.
3. A little bit of CSS
We won't dive into styling in this tutorial, so we created a CSS file used throughout all our blog tutorials. Whether you do the Next.js, Gatsby, or Nuxt.js tutorial, you will always have the same front-end result.
./frontend/assets/css/style.css
file containing the following:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
a {
text-decoration: none;
}
h1 {
font-family: Staatliches;
font-size: 120px;
}
#category {
font-family: Staatliches;
font-weight: 500;
}
#title {
letter-spacing: 0.4px;
font-size: 22px;
font-size: 1.375rem;
line-height: 1.13636;
}
#banner {
margin: 20px;
height: 800px;
}
#editor {
font-size: 16px;
font-size: 1rem;
line-height: 1.75;
}
.uk-navbar-container {
background: #fff !important;
font-family: Staatliches;
}
img:hover {
opacity: 1;
transition: opacity 0.25s cubic-bezier(0.39, 0.575, 0.565, 1);
}
4. _app.js
Let's create some pages now!
First, you need to override the default ./frontend/pages/_app.js
to add Uikit, the Staatliches font, but more importantly, you need to fetch the Global site settings, which are the Global single-type in Strapi.
./frontend/pages/_app.js
file with the following:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import App from "next/app";
import Head from "next/head";
import "../assets/css/style.css";
import { createContext } from "react";
import { fetchAPI } from "../lib/api";
import { getStrapiMedia } from "../lib/media";
// Store Strapi Global object in context
export const GlobalContext = createContext({});
const MyApp = ({ Component, pageProps }) => {
const { global } = pageProps;
return (
<>
<Head>
<link
rel="shortcut icon"
href={getStrapiMedia(global.attributes.favicon)}
/>
</Head>
<GlobalContext.Provider value={global.attributes}>
<Component {...pageProps} />
</GlobalContext.Provider>
</>
);
};
// getInitialProps disables automatic static optimization for pages that don't
// have getStaticProps. So article, category and home pages still get SSG.
// Hopefully we can replace this with getStaticProps once this issue is fixed:
// https://github.com/vercel/next.js/discussions/10949
MyApp.getInitialProps = async (ctx) => {
// Calls page's `getInitialProps` and fills `appProps.pageProps`
const appProps = await App.getInitialProps(ctx);
// Fetch global site settings from Strapi
const globalRes = await fetchAPI("/global", {
populate: {
favicon: "*",
defaultSeo: {
populate: "*",
},
},
});
// Pass the data to our page via props
return { ...appProps, pageProps: { global: globalRes.data } };
};
export default MyApp;
You can see here that the fetchAPI function created earlier is used to fetch the Global single-type in Strapi. Then it gets stored in a context.
Let's override the _document.js
file to include UIkit and the Staatliches font in our app
./pages/_document.js
file with the following content:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import Document, { Html, Head, Main, NextScript } from "next/document";
class MyDocument extends Document {
render() {
return (
<Html>
<Head>
{/* eslint-disable-next-line */}
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Staatliches"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/uikit@3.10.1/dist/css/uikit.min.css"
/>
<script
async
src="https://cdnjs.cloudflare.com/ajax/libs/uikit/3.2.0/js/uikit.min.js"
/>
<script
async
src="https://cdn.jsdelivr.net/npm/uikit@3.10.1/dist/js/uikit-icons.min.js"
/>
<script
async
src="https://cdnjs.cloudflare.com/ajax/libs/uikit/3.2.0/js/uikit.js"
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
}
export default MyDocument;
5. Components! Lot of them!
Before going any further with the pages, you first need to create some components that will be necessary to build them out. For this tutorial, you need the following components:
Card component
Create a ./frontend/components
folder.
./frontend/components/nav.js
component containing the following:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import React from "react";
import Link from "next/link";
const Nav = ({ categories }) => {
return (
<div>
<nav className="uk-navbar-container" data-uk-navbar>
<div className="uk-navbar-left">
<ul className="uk-navbar-nav">
<li>
<Link href="/">
<a>Strapi Blog</a>
</Link>
</li>
</ul>
</div>
<div className="uk-navbar-right">
<ul className="uk-navbar-nav">
{categories.map((category) => {
return (
<li key={category.id}>
<Link href={`/category/${category.attributes.slug}`}>
<a className="uk-link-reset">{category.attributes.name}</a>
</Link>
</li>
);
})}
</ul>
</div>
</nav>
</div>
);
};
export default Nav;
You can see here that this component receives some categories as a prop. Don't forget that when you declare it.
./frontend/components/layout.js
component containing the following:1
2
3
4
5
6
7
8
9
10
import Nav from "./nav";
const Layout = ({ children, categories, seo }) => (
<>
<Nav categories={categories} />
{children}
</>
);
export default Layout;
This component calls the nav component.
./frontend/components/seo.js
component containing the following:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import Head from "next/head";
import { useContext } from "react";
import { GlobalContext } from "../pages/_app";
import { getStrapiMedia } from "../lib/media";
const Seo = ({ seo }) => {
const { defaultSeo, siteName } = useContext(GlobalContext);
const seoWithDefaults = {
...defaultSeo,
...seo,
};
const fullSeo = {
...seoWithDefaults,
// Add title suffix
metaTitle: `${seoWithDefaults.metaTitle} | ${siteName}`,
// Get full image URL
shareImage: getStrapiMedia(seoWithDefaults.shareImage),
};
return (
<Head>
{fullSeo.metaTitle && (
<>
<title>{fullSeo.metaTitle}</title>
<meta property="og:title" content={fullSeo.metaTitle} />
<meta name="twitter:title" content={fullSeo.metaTitle} />
</>
)}
{fullSeo.metaDescription && (
<>
<meta name="description" content={fullSeo.metaDescription} />
<meta property="og:description" content={fullSeo.metaDescription} />
<meta name="twitter:description" content={fullSeo.metaDescription} />
</>
)}
{fullSeo.shareImage && (
<>
<meta property="og:image" content={fullSeo.shareImage} />
<meta name="twitter:image" content={fullSeo.shareImage} />
<meta name="image" content={fullSeo.shareImage} />
</>
)}
{fullSeo.article && <meta property="og:type" content="article" />}
<meta name="twitter:card" content="summary_large_image" />
</Head>
);
};
export default Seo;
This component creates the necessary meta tags for the SEO of your blog.
It uses the GlobalContext created in the ./frontends/pages/_app.js
file and the getStrapiMedia function created earlier to fetch the correct default image for your SEO.
./frontend/components/image.js
component containing the following:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { getStrapiMedia } from "../lib/media";
import NextImage from "next/image";
const Image = ({ image }) => {
const { alternativeText, width, height } = image.data.attributes;
return (
<NextImage
layout="responsive"
width={width}
height={height}
objectFit="contain"
src={getStrapiMedia(image)}
alt={alternativeText || ""}
/>
);
};
export default Image;
Useful component for displaying images.
Please stick with me; only 2 components left to create!
./frontend/components/articles.js
component containing the following:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import React from "react";
import Card from "./card";
const Articles = ({ articles }) => {
const leftArticlesCount = Math.ceil(articles.length / 5);
const leftArticles = articles.slice(0, leftArticlesCount);
const rightArticles = articles.slice(leftArticlesCount, articles.length);
return (
<div>
<div className="uk-child-width-1-2@s" data-uk-grid="true">
<div>
{leftArticles.map((article, i) => {
return (
<Card
article={article}
key={`article__left__${article.attributes.slug}`}
/>
);
})}
</div>
<div>
<div className="uk-child-width-1-2@m uk-grid-match" data-uk-grid>
{rightArticles.map((article, i) => {
return (
<Card
article={article}
key={`article__left__${article.attributes.slug}`}
/>
);
})}
</div>
</div>
</div>
</div>
);
};
export default Articles;
This component splits your list of articles (for styling purposes) on your blog's right and left-hand sides. As you can see, it calls a component that I promise will be the last one you need to create!
./frontend/components/card.js
component containing the following:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import React from "react";
import Link from "next/link";
import NextImage from "./image";
const Card = ({ article }) => {
return (
<Link href={`/article/${article.attributes.slug}`}>
<a className="uk-link-reset">
<div className="uk-card uk-card-muted">
<div className="uk-card-media-top">
<NextImage image={article.attributes.image} />
</div>
<div className="uk-card-body">
<p id="category" className="uk-text-uppercase">
{article.attributes.category.data.attributes.name}
</p>
<p id="title" className="uk-text-large">
{article.attributes.title}
</p>
</div>
</div>
</a>
</Link>
);
};
export default Card;
Just a simple component that will be used for displaying the article on the main page. It uses the Image component we just created earlier. And we're done with the components.
You’re now ready to create your pages!
6. Pages
The first thing to do is to override the default ./frontend/pages/index.js
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import React from "react";
import Articles from "../components/articles";
import Layout from "../components/layout";
import Seo from "../components/seo";
import { fetchAPI } from "../lib/api";
const Home = ({ articles, categories, homepage }) => {
return (
<Layout categories={categories}>
<Seo seo={homepage.attributes.seo} />
<div className="uk-section">
<div className="uk-container uk-container-large">
<h1>{homepage.attributes.hero.title}</h1>
<Articles articles={articles} />
</div>
</div>
</Layout>
);
};
export async function getStaticProps() {
// Run API calls in parallel
const [articlesRes, categoriesRes, homepageRes] = await Promise.all([
fetchAPI("/articles", { populate: ["image", "category"] }),
fetchAPI("/categories", { populate: "*" }),
fetchAPI("/homepage", {
populate: {
hero: "*",
seo: { populate: "*" },
},
}),
]);
return {
props: {
articles: articlesRes.data,
categories: categoriesRes.data,
homepage: homepageRes.data,
},
revalidate: 1,
};
}
export default Home;
As you can see here, the application fetches the Homepage single-type and the Category and Article collection-types. It then displays all of that using the components you just created.
Let's explain what happens for the hommepage
call:
1
2
3
4
5
6
fetchAPI("/homepage", {
populate: {
hero: "*",
seo: { populate: "*" },
},
}),
By default on Strapi v4, the REST API is no longer populating components, relations, and dynamic zones for performance purposes (only fetching the minimum and specifying what you need).
If you want to fetch them, you'll need to use the populate param. Here we say: for the homepage single-type, we want to populate the hero component and get all its fields ("*"
) and the SEO component. You can see that you can specify it in different ways.
Imagine that the hero component has a component inside and that you want to fetch it also, you should write something like this:
1
2
3
4
5
6
fetchAPI("/homepage", {
populate: {
hero: { populate: { anotherCompo: { populate: "*" } }},
seo: { populate: "*" },
},
}),
Alright, enough suspense! You can now launch your Next.js app if you haven’t already or reload it using the following command:
yarn dev
Now, you need to create a page for each one of your articles.
./frontend/pages/article
folder../frontend/pages/article/[slug].js
file containing the following:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import Moment from "react-moment";
import ReactMarkdown from "react-markdown";
import Seo from "../../components/seo";
import Layout from "../../components/layout";
import { fetchAPI } from "../../lib/api";
import { getStrapiMedia } from "../../lib/media";
const Article = ({ article, categories }) => {
const imageUrl = getStrapiMedia(article.attributes.image);
const seo = {
metaTitle: article.attributes.title,
metaDescription: article.attributes.description,
shareImage: article.attributes.image,
article: true,
};
return (
<Layout categories={categories.data}>
<Seo seo={seo} />
<div
id="banner"
className="uk-height-medium uk-flex uk-flex-center uk-flex-middle uk-background-cover uk-light uk-padding uk-margin"
data-src={imageUrl}
data-srcset={imageUrl}
data-uk-img
>
<h1>{article.attributes.title}</h1>
</div>
<div className="uk-section">
<div className="uk-container uk-container-small">
<ReactMarkdown children={article.attributes.content} />
<hr className="uk-divider-small" />
<div className="uk-grid-small uk-flex-left" data-uk-grid="true">
<div>
{article.attributes.author.data.attributes.picture && (
<img
src={getStrapiMedia(
article.attributes.author.data.attributes.picture
)}
alt={
article.attributes.author.data.attributes.picture.data
.attributes.alternativeText
}
style={{
position: "static",
borderRadius: "20%",
height: 60,
}}
/>
)}
</div>
<div className="uk-width-expand">
<p className="uk-margin-remove-bottom">
By {article.attributes.author.data.attributes.name}
</p>
<p className="uk-text-meta uk-margin-remove-top">
<Moment format="MMM Do YYYY">
{article.attributes.published_at}
</Moment>
</p>
</div>
</div>
</div>
</div>
</Layout>
);
};
export async function getStaticPaths() {
const articlesRes = await fetchAPI("/articles", { fields: ["slug"] });
return {
paths: articlesRes.data.map((article) => ({
params: {
slug: article.attributes.slug,
},
})),
fallback: false,
};
}
export async function getStaticProps({ params }) {
const articlesRes = await fetchAPI("/articles", {
filters: {
slug: params.slug,
},
populate: ["image", "category", "author.picture"],
});
const categoriesRes = await fetchAPI("/categories");
return {
props: { article: articlesRes.data[0], categories: categoriesRes },
revalidate: 1,
};
}
export default Article;
So there’s a lot of information to unpack here. Let’s break it down into separate sections to make it easier to understand.
First of all, it declares the image and SEO of the article.
1
2
3
4
5
6
7
8
const imageUrl = getStrapiMedia(article.attributes.image);
const seo = {
metaTitle: article.attributes.title,
metaDescription: article.attributes.description,
shareImage: article.attributes.image,
article: true,
};
Since this page has a dynamic route, the article’s path needs to be defined using its slug. You can find more information about pre-rendering all the paths specified by getStaticPaths.
1
2
3
4
5
6
7
8
9
10
11
12
export async function getStaticPaths() {
const articlesRes = await fetchAPI("/articles", { fields: ["slug"] });
return {
paths: articlesRes.data.map((article) => ({
params: {
slug: article.attributes.slug,
},
})),
fallback: false,
};
}
Then you need to define the props using getStaticProps. You need to fetch the date of the article and the categories.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export async function getStaticProps({ params }) {
const articlesRes = await fetchAPI("/articles", {
filters: {
slug: params.slug,
},
populate: ["image", "category", "author.picture"],
});
const categoriesRes = await fetchAPI("/categories");
return {
props: { article: articlesRes.data[0], categories: categoriesRes },
revalidate: 1,
};
}
Now, if you reload your Next.js application and if you click on any article, it should be working properly!
You now need to go through the exact same process as you did for the article page.
./frontend/pages/category
folder../frontend/pages/category/[slug].js
file containing the following:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import Seo from "../../components/seo";
import Layout from "../../components/layout";
import Articles from "../../components/articles";
import { fetchAPI } from "../../lib/api";
const Category = ({ category, categories }) => {
const seo = {
metaTitle: category.attributes.name,
metaDescription: `All ${category.attributes.name} articles`,
};
return (
<Layout categories={categories.data}>
<Seo seo={seo} />
<div className="uk-section">
<div className="uk-container uk-container-large">
<h1>{category.attributes.name}</h1>
<Articles articles={category.attributes.articles.data} />
</div>
</div>
</Layout>
);
};
export async function getStaticPaths() {
const categoriesRes = await fetchAPI("/categories", { fields: ["slug"] });
return {
paths: categoriesRes.data.map((category) => ({
params: {
slug: category.attributes.slug,
},
})),
fallback: false,
};
}
export async function getStaticProps({ params }) {
const matchingCategories = await fetchAPI("/categories", {
filters: { slug: params.slug },
populate: {
articles: {
populate: "*",
},
},
});
const allCategories = await fetchAPI("/categories");
return {
props: {
category: matchingCategories.data[0],
categories: allCategories,
},
revalidate: 1,
};
}
export default Category;
As you can see, it’s very similar to the article page. This allows you to filter your list of articles in a specific category. Try it out for yourself!
Conngratulations, you completed this tutorial, and you can be proud of yourself. I hope you enjoyed it!
Please note: Since we initially published this blog, we released new versions of Strapi, and tutorials may be outdated. Sorry for the inconvenience if it's the case, and please help us by reporting it here.
Get started with Strapi by creating a project using a starter or trying our live demo. Also, consult our forum if you have any questions. We will be there to help you.
See you soon!
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
Get all the latest Strapi updates, news and events.