Headless Content Management Systems are powerful in many ways; they give us the flexibility to do whatever we want to do with our preferred frontend technology. Strapi is one of the most popular headless CMSes out there, and it makes working with the backend side of things a breeze.
This tutorial will discuss how to make a photo gallery with Strapi and Next.js, using Cloudinary to store our images.
To follow along with this tutorial, you should have the following:
A Cloudinary account
One of the first things you will need to do is to create a free account on Cloudinary. Once you have successfully created your free account, you will be redirected to the management dashboard of your account. On the dashboard page, you will find your Account Details, which you will need to save for later:
Make sure to keep these details secret and do not share them with anyone.
After creating a Cloudinary account, it is time to install your Strapi instance. Run the following command:
1 yarn create strapi-app strapi-photo --quickstart
2 #OR
3 npm create strapi-app strapi-photo --quickstart
This command will create a folder named strapi-photo
and install the Strapi instance to it.
After installation, Strapi will automatically run a build command at http://localhost:1337/admin
, redirecting you immediately http://localhost:1337/admin/auth/register-admin
because it is the first time you are starting it. You will need to register yourself as a superuser
.
Now, it is time to create your first collection. Click on Content-Types Builder and then click on Create new collection type.
Type Photos for your Display name and click on the Continue button to add fields.
We will be adding four fields, which are name, date, location, and img. Follow the instructions below:
Before adding data to the Photos collection we have created, we need to connect our Cloudinary account to the Strapi instance. It would help if you stopped the server before you run the command below.
yarn add @strapi/provider-upload-cloudinary
#OR
npm install @strapi/provider-upload-cloudinary
yarn run develop
#OR
npm run develop
*plugins.js*
inside the ***config***
folder, and paste the following code into it:1 module.exports = ({ env }) => ({
2 upload: {
3 config: {
4 provider: 'cloudinary',
5 providerOptions: {
6 cloud_name: env('CLOUDINARY_NAME'),
7 api_key: env('CLOUDINARY_KEY'),
8 api_secret: env('CLOUDINARY_SECRET'),
9 },
10 actionOptions: {
11 upload: {},
12 delete: {},
13 },
14 },
15 },
16 });
Add the following variables in the .env
file. Fill the missing values with the corresponding values found in your Cloudinary dashboard under Account Details, and make sure to restart your server.
CLOUDINARY_NAME=xxxxxxxxxxxxxxxxxxxxxx CLOUDINARY_KEY=xxxxxxxxxxxxxxxxxx CLOUDINARY_SECRET=xxxxxxxxxxxxxxxx
If you using Strapi v3, you might likely run into an error when you restart your server:
12022-03-22 15:23:23.455] debug: Server wasn’t able to start properly.
2[2022-03-22 15:23:23.456] error: Middleware “strapi::session”: App keys are required. Please set app.keys in config/server.js (ex: keys: [‘myKeyA’, ‘myKeyB’])
3Error: Middleware “strapi::session”: App keys are required. Please set app.keys in config/server.js (ex: keys: [‘myKeyA’, ‘myKeyB’])
To fix this error, ensure your .env
file has the necessary variables which can be random strings in place of the missing values. The content of the .env
file should be similar to the code snippets below:
1HOST=0.0.0.0
2PORT=1337
3APP_KEYS=xxxxxxxxxxxxx,xxxxxxxxxxxxx #Note that this is comma seperated
4ADMIN_JWT_SECRET=xxxxxxxxxxxx
5API_TOKEN_SALT=xxxxxxxxxxxxxx
6CLOUDINARY_NAME=xxxxxxxxxxxx
7CLOUDINARY_API_KEY=xxxxxxxxxxxxxxxxx
8CLOUDINARY_API_SECRET=xxxxxxxxxxxxxxxx
9JWT_SECRET=xxxxxxxxxxxxxxxxxxxxx
After adding the missing variables, the server can be restarted. It is however best that you upgrade to Strapi v4 to avoid such errors in the future.
Go back to your Strapi project at http://localhost:1337/admin and click on Content Manager. Click on Photos, then Add New Photos.
I have decided to use J Cole’s and Vector’s pictures for this. You can use any image you want to follow along. Make sure you save and publish.
I have added four entries.
Log in to your Cloudinary to make sure the images are there.
To make these data available for consumption by any client-side technology, we need to set some roles and permissions — who has access to what and to what extent.
Now go to Settings→(USER & PERMISSION PLUGIN)→Roles→Public
Yes, we have successfully spun up the backend side of things in our application. Now let us use Next.js to consume its API. Exit your Strapi instance folder and run the following command to install Next.js.
yarn create next-app next-photo
#OR
npm create next-app next-photo
This command sets up everything automatically for us (next-photo is my folder name, you can name yours differently)
Move into next-photo
:
cd next-photo
yarn dev
#OR
npm run dev
One of the main benefits of Next.js applications is that everything is pre-rendered or built at first load. At http://localhost:3000, we should see a default Next.js instance:
Since we will be working with images from an external source, Cloudinary, we need to configure the *next.config.js*
file for image optimization that NextJS provides. Make sure to upload images greater than the sizes listed below for better optimization.
1 const nextConfig = {
2 //..
3 images: {
4 deviceSizes: [320, 420, 768, 1024, 1200],
5 loader: "default",
6 domains: ["res.cloudinary.com"],
7 },
8 }
9 module.exports = nextConfig
Now, we are going to create a components
folder and ImageDetail.js
file within it. Paste the following code inside:
1 import Image from "next/image";
2 import Link from "next/link";
3 export default function Gallery({ thumbnailUrl, title, id }) {
4 return (
5 <div>
6 <Link as={`/preview/${id}`} href="/preview/[id]">
7 <a>
8 <Image width={250} height={200} src={thumbnailUrl} />
9 <div className="photoid"> {title}</div>
10 </a>
11 </Link>
12 </div>
13 );
14 }
After importing Image
and Link
from next
, a gallery-component
has three props ( thumbnailUrl, title, id)
and returning a link
that will dynamically redirect to preview/$id
of each photo in our backend. I have decided to make the width and height 250px and 200px, respectively.
Create another folder named preview
in the pages
folder and create a file with square brackets like so [id].js
inside the just created folder.
We will come back to this file, but for now, go to your index.js
file in pages
folder and replace the existing code with this:
1 import Head from "next/head";
2 import { useState } from "react";
3 import Gallery from "../components/ImageDetail";
4 import styles from "../styles/Home.module.css";
5 export default function Home({ stuff }) {
6 const [photos, setPhotos] = useState(stuff);
7 const [search, setSearch] = useState("");
8 return (
9 <div className={styles.container}>
10 <Head>
11 <title>Photo Gallery</title>
12 <link rel="icon" href="/favicon.ico" />
13 </Head>
14 <main className={styles.main}>
15 <div className={styles.fade}>
16 <div className={styles.gridContainer}>
17 {photos &&
18 photos.data.map((detail) => (
19 <Gallery
20 key={detail.id}
21 thumbnailUrl={detail.attributes.img.data.attributes.formats.thumbnail.url}
22 title={detail.attributes.name}
23 id={detail.id}
24 />
25 ))}
26 </div>
27 </div>
28 </main>
29 </div>
30 );
31 }
32 export async function getStaticProps() {
33 const results = await fetch("http://localhost:1337/api/photos?populate=*");
34 const stuff = await results.json();
35 return {
36 props: { stuff },
37 };
38 }
We imported and used Gallery
from the ImageDetail.js
in our components
folder. We mapped through every instance of photos states we created. Line 32
is essential here because it uses a Next.js, getStaticProps
, which fetches data at build time from our Strapi instance at http://localhost:1337/api/photos
.
Your application should look like this:
Let us make everything responsive with the following steps.
Home.module.css
in the styles
folder.global.css
in the styles
folder.Your application should now look like this:
We have gotten the home page up and running. It'd be nice to have a search input field where users can find a specific image by its name. This will be most useful when the photos get populated.
In your index.js
file add the following code immediately after the opening of the <main>
tag:
1 <input
2 onChange={(e) => setSearch(e.target.value)}
3 className={styles.searchInput}
4 type="text"
5 placeholder="Search for an image"
6 ></input>
7 <button
8 className="button"
9 disabled={search === ""}
10 onClick={async () => {
11 const results = await fetch(
12 `http://localhost:1337/api/photos?populate=*&filters\[name\][$eq]=${search}`
13 );
14 const details = await results.json();
15 setPhotos(await details);
16 }}
17 >
18 Find
19 </button>
Line 1 to 6
takes care of the input. It targets the value in the input field. Pay attention to what is being fetched at Line 12
. It uses filtering techniques. You can read more about it here. Make sure you had set a search state. Your final index.js
file should look like this:
1 import Head from "next/head";
2 import { useState } from "react";
3 import Gallery from "../components/ImageDetail";
4 import styles from "../styles/Home.module.css";
5 export default function Home({ stuff }) {
6 const [photos, setPhotos] = useState(stuff);
7 const [search, setSearch] = useState("");
8 return (
9 <div className={styles.container}>
10 <Head>
11 <title>Photo Gallery</title>
12 <link rel="icon" href="/favicon.ico" />
13 </Head>
14 <main className={styles.main}>
15 <input
16 onChange={(e) => setSearch(e.target.value)}
17 className={styles.searchInput}
18 type="text"
19 placeholder="Search for an image"
20 ></input>
21 <button
22 className="button"
23 disabled={search === ""}
24 onClick={async () => {
25 const results = await fetch(
26 `http://localhost:1337/api/photos?populate=*&filters\[name\][$eq]=${search}`
27 );
28 const details = await results.json();
29 setPhotos(await details);
30 }}
31 >
32 Find
33 </button>
34 <div className={styles.fade}>
35 <div className={styles.gridContainer}>
36 {photos &&
37 photos.data.map((detail) => (
38 <Gallery
39 key={detail.id}
40 thumbnailUrl={detail.attributes.img.data.attributes.formats.thumbnail.url}
41 title={detail.attributes.name}
42 id={detail.id}
43 />
44 ))}
45 </div>
46 </div>
47 </main>
48 </div>
49 );
50 }
51 export async function getStaticProps() {
52 const results = await fetch("http://localhost:1337/api/photos?populate=*");
53 const stuff = await results.json();
54 return {
55 props: { stuff },
56 };
57 }
Your application should look like so with the search input and Find button:
When you do a Search and hit Find, this is how it should look:
Now, it is time to take care of what happens when a photo is clicked. Remember that our Gallery component in ImageDetail.js
inside the component
folder has a Link. Clicking on any photos right now will produce this error page:
This is because nothing has been done inside the [id].js
we created inside the preview
folder. Let us fix this. To fix the error, paste the following code inside [id].js
.
1 import { useRouter } from "next/router";
2 import Image from "next/image";
3 import Link from "next/link";
4 export default function photo({ photo, location, name, date }) {
5 const router = useRouter();
6 if (!router.isFallback && !photo) {
7 return <ErrorPage statusCode={404} />;
8 }
9 return (
10 <div>
11 <div className="Imagecontainer">
12 <Link className="homeButton" href="/">
13 <a className="homeButton">
14 <button className="button"> Home </button>
15 </a>
16 </Link>
17 </div>
18 <div className="Imagecontainer">
19 {router.isFallback ? (
20 <div>Loading…</div>
21 ) : (
22 <>
23 <Image width={960} priority height={540} src={photo} />
24 </>
25 )}
26 </div>
27 <div className="Imagecontainer">Name : {name}</div>
28 <div className="Imagecontainer">Location {location}</div>
29 <div className="Imagecontainer">Date: {date}</div>
30 <div className="Imagecontainer">
31 <Link className="homeButton" href="/">
32 <a className="homeButton">
33 <button className="button"> Back </button>
34 </a>
35 </Link>
36 </div>
37 </div>
38 );
39 }
40 export async function getStaticProps({ params }) {
41 const photoid = params.id;
42 const results = await fetch(`http://localhost:1337/api/photos/${photoid}?populate=*`);
43 const previews = await results.json();
44 const photo = await previews.data.attributes.img.data.attributes.url;
45 const name = await previews.data.attributes.name;
46 const location = previews.data.attributes.location;
47 const date = await previews.data.attributes.createdAt.toString();
48 return {
49 props: { photo, name, location, date },
50 };
51 }
52 export async function getStaticPaths() {
53 const results = await fetch("http://localhost:1337/api/photos?populate=*");
54 const previews = await results.json();
55 return {
56 paths:
57 previews?.data.map((pic) => ({
58 params: { id: pic.id.toString() },
59 })) || [],
60 fallback: true,
61 };
62 }
I will explain what most parts of this code do.
The getStaticPaths
in from Line 52
is a Next.js primary data-fetching method required because of our application's dynamic routes. Read more about it static generation.
The getStaticProps
will fetch the params.id
defined in getStaticPaths
. Since that is available, we then fetch each id dynamically it JSON in Line 43
before accessing each of the things we need.
Line 27 to 29
displays all other fields (location, name, date) right below the image component showing each image detail in 960px x 540px. Note that we have already defined them as props in Line 4, our photo component.
If you did everything right, you should have yourself something like this yourself when you click any photo.
We set up and connected our Cloudinary account to the Strapi instance. In addition, we played around Strapi and its permissions and roles, thereby creating our collection to suit what we have in mind.
We talked about Next.js and some of its out-of-the-box methods like getStaticProps
and getStaticPaths
. Finally, we were able to put all these together to build our photo gallery app.
The repository for the frontend implementation and backend implementation can be found on Github.
Backend Developer 👩💻 | Technical Writer ✍️