Using transformations to create a photo collage

Eugene Musebe

Introduction

You have probably used those collage-making apps, either as a native app on your phone or a web app. In this short tutorial, we'll be looking to achieve the same using some cleverly designed layouts, cloudinary and next.js. We're going to be using Cloudinary transformations to overlay the images so they match our layout.

Codesandbox

The final project demo is available on Codesandbox.

Get the GitHub source code here Github

Prerequisites and setup

You need to have a Cloudinary account. If you do not have one you can register for free here. You will also need to have Node.js and NPM or Yarn installed in your development environment. Working knowledge of Javascript and React is required. Knowledge of Node.js and Next.js is a plus but not required.

Creating a new project

You can easily create a Next.js project by running the following command in your terminal:

1npx create-next-app@latest photo-collage-with-cloudinary

The command scaffolds a new project with the name photo-collage-with-cloudinary. You can use any appropriate name. For more information on getting started with Next.js and additional options, check out the docs. Change directory to your newly created folder

1cd photo-collage-with-cloudinary

You can proceed to open the folder in your favorite code editor.

Getting Cloudinary API credentials

Assuming you already have a Cloudinary account at this point, head over to the cloudinary console page. On this page, you'll find your cloud name, api key, and api secret.

Create a new file named .env.local at the root of your project(photo-collage-with-cloudinary folder). Paste the following inside .env.local

1CLOUD_NAME=YOUR_CLOUD_NAME
2
3API_KEY=YOUR_API_KEY
4
5API_SECRET=YOUR_API_SECRET

Make sure to replace YOUR_CLOUD_NAME, YOUR_API_KEY and YOUR_API_SECRET with the cloud name, api key and api secret values that you got from the cloudinary console page.

We've just defined a few environment variables. Environment variables allow us to keep sensitive keys away from our publicly accessible code. Read about environment variables in node.js. Next.js has built-in support for environment variables. Find out more in the next.js environment variables docs.

Installing libraries and dependencies

These are the dependencies we need to install

  • cloudinary -This is the Cloudinary node SDK. It will make API calls easier.

  • formidable - This is a node.js module for parsing form data. It allows for us to handle file uploads

  • canvas - This is a canvas implementation for the server(node.js)

Run the following command to install them

1npm install cloudinary formidable canvas

Getting started.

Let's start by creating some layouts that we can use to create our collages. Create a new folder called lib at the root of your project. Create a new file called collageLayouts.js inside this folder. Paste the following inside lib/collageLayouts.js.

1/**
2
3* @typedef {Object} CollageLayout
4
5* @property {number} id
6
7* @property {number} width
8
9* @property {number} height
10
11* @property {() => CollageLayout[]} sections
12
13*/
14
15
16
17/**
18
19* @typedef {Object} CollageSection
20
21* @property {number} width
22
23* @property {number} height
24
25* @property {number} x
26
27* @property {number} y
28
29*/
30
31
32
33/**
34
35* Pre-defined layouts. You can add more layouts here. Make sure each has a unique id.
36
37*
38
39* @type {CollageLayout[]}
40
41*/
42
43export const layouts = [
44
45{
46
47id: 1,
48
49width: 800,
50
51height: 800,
52
53sections: function () {
54
55return [
56
57{
58
59width: this.width * 0.5,
60
61height: this.height * 0.4,
62
63x: 0,
64
65y: 0,
66
67},
68
69{
70
71width: this.width * 0.5,
72
73height: this.height,
74
75x: this.width * 0.5,
76
77y: 0,
78
79},
80
81{
82
83width: this.width * 0.5,
84
85height: this.height * 0.6,
86
87x: 0,
88
89y: this.height * 0.4,
90
91},
92
93];
94
95},
96
97},
98
99{
100
101id: 2,
102
103width: 800,
104
105height: 400,
106
107sections: function () {
108
109return [
110
111{
112
113width: this.width * 0.5,
114
115height: this.height,
116
117x: 0,
118
119y: 0,
120
121},
122
123{
124
125width: this.width * 0.5,
126
127height: this.height,
128
129x: this.width * 0.5,
130
131y: 0,
132
133},
134
135];
136
137},
138
139},
140
141{
142
143id: 3,
144
145width: 800,
146
147height: 800,
148
149sections: function () {
150
151return [
152
153{
154
155width: this.width * 0.5,
156
157height: this.height * 0.5,
158
159x: 0,
160
161y: 0,
162
163},
164
165{
166
167width: this.width * 0.5,
168
169height: this.height * 0.5,
170
171x: this.width * 0.5,
172
173y: 0,
174
175},
176
177{
178
179width: this.width,
180
181height: this.height * 0.5,
182
183x: 0,
184
185y: this.height * 0.5,
186
187},
188
189];
190
191},
192
193},
194
195{
196
197id: 4,
198
199width: 800,
200
201height: 800,
202
203sections: function () {
204
205return [
206
207{
208
209width: this.width,
210
211height: this.height * 0.5,
212
213x: 0,
214
215y: 0,
216
217},
218
219{
220
221width: this.width * 0.5,
222
223height: this.height * 0.5,
224
225x: 0,
226
227y: this.height * 0.5,
228
229},
230
231{
232
233width: this.width * 0.5,
234
235height: this.height * 0.5,
236
237x: this.width * 0.5,
238
239y: this.height * 0.5,
240
241},
242
243];
244
245},
246
247},
248
249{
250
251id: 5,
252
253width: 800,
254
255height: 600,
256
257sections: function () {
258
259return [
260
261{
262
263width: this.width * 0.4,
264
265height: this.height,
266
267x: 0,
268
269y: 0,
270
271},
272
273{
274
275width: this.width * 0.6,
276
277height: this.height * 0.5,
278
279x: this.width * 0.4,
280
281y: 0,
282
283},
284
285{
286
287width: this.width * 0.6,
288
289height: this.height * 0.5,
290
291x: this.width * 0.4,
292
293y: this.height * 0.5,
294
295},
296
297];
298
299},
300
301},
302
303{
304
305id: 6,
306
307width: 800,
308
309height: 800,
310
311sections: function () {
312
313return [
314
315{
316
317width: this.width,
318
319height: this.height * 0.25,
320
321x: 0,
322
323y: 0,
324
325},
326
327{
328
329width: this.width,
330
331height: this.height * 0.25,
332
333x: 0,
334
335y: this.height * 0.25,
336
337},
338
339{
340
341width: this.width,
342
343height: this.height * 0.25,
344
345x: 0,
346
347y: this.height * 0.5,
348
349},
350
351{
352
353width: this.width,
354
355height: this.height * 0.25,
356
357x: 0,
358
359y: this.height * 0.75,
360
361},
362
363];
364
365},
366
367},
368
369];

At the top we have some jsdoc typedefs. This is just a neat jsdoc feature that lets us define custom types without the need for typescript.

We export an array called layouts. The array contains a bunch of objects. These objects are our layouts. Let's first understand why we need this data. Every layout is a container of a certain width and height. It also contains a unique id. Each container is divided into smaller containers that we can call sections. The sections are what make up the collage layout. We need to know the width and height of each section and also where to place the section relative to the parent container. We can play with the width,height,x, and y values to create different layouts.


Create a new file under the lib folder and name it parse-form.js. Paste the following inside lib/parse-form.js.

1import { IncomingForm, Files, Fields } from "formidable";
2
3
4
5/**
6
7* Parses the incoming form data.
8
9*
10
11* @param {NextApiRequest} req The incoming request object
12
13* @returns {Promise<{fields:Fields;files:Files;}>} The parsed form data
14
15*/
16
17export const parseForm = (req) => {
18
19return new Promise((resolve, reject) => {
20
21const form = new IncomingForm({ keepExtensions: true, multiples: true });
22
23
24
25form.parse(req, (error, fields, files) => {
26
27if (error) {
28
29return reject(error);
30
31}
32
33
34
35return resolve({ fields, files });
36
37});
38
39});
40
41};

The code inside this file is responsible for parsing incoming form data using the formidable package that we installed earlier. Check out the formidable docs documents for further explanation.


Create a file called cloudinary.js under the lib folder. Paste the following inside lib/cloudinary.js

1// Import the v2 api and rename it to cloudinary
2
3import { v2 as cloudinary, TransformationOptions } from "cloudinary";
4
5
6
7// Initialize the SDK with cloud_name, api_key, and api_secret
8
9cloudinary.config({
10
11cloud_name: process.env.CLOUD_NAME,
12
13api_key: process.env.API_KEY,
14
15api_secret: process.env.API_SECRET,
16
17});
18
19
20
21const CLOUDINARY_FOLDER_NAME = "photo-collage/";
22
23
24
25/**
26
27* Get cloudinary uploads
28
29* @param {string} folder Folder name
30
31* @returns {Promise}
32
33*/
34
35export const handleGetCloudinaryUploads = (folder = CLOUDINARY_FOLDER_NAME) => {
36
37return cloudinary.api.resources({
38
39type: "upload",
40
41prefix: folder,
42
43resource_type: "image",
44
45});
46
47};
48
49
50
51/**
52
53* @typedef {Object} Resource
54
55* @property {string | Buffer} file
56
57* @property {string} publicId
58
59* @property {boolean} inFolder
60
61* @property {string} folder
62
63* @property {TransformationOptions} transformation
64
65*
66
67*/
68
69
70
71/**
72
73* Uploads an image to cloudinary and returns the upload result
74
75*
76
77* @param {Resource} resource
78
79*/
80
81export const handleCloudinaryUpload = ({
82
83file,
84
85publicId,
86
87transformation,
88
89folder = CLOUDINARY_FOLDER_NAME,
90
91inFolder = false,
92
93}) => {
94
95return cloudinary.uploader.upload(file, {
96
97// Folder to store image in
98
99folder: inFolder ? folder : null,
100
101// Public id of image.
102
103public_id: publicId,
104
105// Type of resource
106
107resource_type: "auto",
108
109// Transformation to apply to the video
110
111transformation,
112
113});
114
115};
116
117
118
119/**
120
121* Deletes resources from cloudinary. Takes in an array of public ids
122
123* @param {string[]} ids
124
125*/
126
127export const handleCloudinaryDelete = (ids) => {
128
129return cloudinary.api.delete_resources(ids, {
130
131resource_type: "image",
132
133});
134
135};

The code inside this folder is responsible for communication with Cloudinary via the SDK we installed earlier. At the top of the file, we import the v2 API from Cloudinary and rename it to cloudinary. You can leave it as v2, we just did this for readability. We then call the .config method on the API to initialize it and authenticate our application. We pass to it the cloud_name, api_key, and api_secret. Remember we defined these as environment variables earlier. CLOUDINARY_FOLDER_NAME defines the folder where we want to store our collage images.

The handleGetCloudinaryUploads function calls the api.resources method on the api. This fetches all resources that have been uploaded to a specific folder. Read about this in the admin api docs.

handleCloudinaryUpload calls the uploader.upload method. This uploads a file to Cloudinary. It takes in an object that contains the file we want to upload, an optional publicId, a transformation object, whether or not to place the file inside a folder, and an optional folder name. Read more about the upload method in the upload docs.

handleCloudinaryDelete passes an array of public IDs to the api.delete_resources method for deletion. Read all about this method in the cloudinary admin api docs.

That's it for the lib folder.


Moving on to our API routes. API routes are a core part of Next.js. Read about API routes in Next.js here.

Create a folder called images inside pages/api. Create a new file called index.js inside pages/api/images. This file will handle http requests made to the /api/images endpoint. Paste the following code inside pages/api/images/index.js.

1import { NextApiRequest, NextApiResponse } from "next";
2
3import { createCanvas } from "canvas";
4
5import { parseForm } from "../../../lib/parse-form";
6
7import {
8
9handleCloudinaryDelete,
10
11handleCloudinaryUpload,
12
13handleGetCloudinaryUploads,
14
15} from "../../../lib/cloudinary";
16
17
18
19export const config = {
20
21api: {
22
23bodyParser: false,
24
25},
26
27};
28
29
30
31/**
32
33*
34
35* @param {NextApiRequest} req
36
37* @param {NextApiResponse} res
38
39*/
40
41export default async function handler(req, res) {
42
43const { method } = req;
44
45
46
47switch (method) {
48
49case "GET": {
50
51try {
52
53const result = await handleGetRequest();
54
55
56
57return res.status(200).json({ message: "Success", result });
58
59} catch (error) {
60
61console.error(error);
62
63return res.status(400).json({ message: "Error", error });
64
65}
66
67}
68
69
70
71case "POST": {
72
73try {
74
75const result = await handlePostRequest(req);
76
77
78
79return res.status(201).json({ message: "Success", result });
80
81} catch (error) {
82
83console.error(error);
84
85return res.status(400).json({ message: "Error", error });
86
87}
88
89}
90
91
92
93default: {
94
95return res.status(405).json({ message: "Method not allowed" });
96
97}
98
99}
100
101}
102
103
104
105async function handleGetRequest() {
106
107return handleGetCloudinaryUploads();
108
109}
110
111
112
113/**
114
115*
116
117* @param {NextApiRequest} req
118
119*/
120
121async function handlePostRequest(req) {
122
123console.log("post received");
124
125// Get the form data using the parseForm function
126
127const data = await parseForm(req);
128
129
130
131// Get the layout data
132
133const layout = JSON.parse(data.fields["layout"]);
134
135
136
137// The transformation object that will be passed to cloudinary to overlay the different images
138
139const transformation = [];
140
141
142
143// Loop through the uploaded images, upload each to cloudinary and populate the transformation array
144
145for (const [key, file] of Object.entries(data.files)) {
146
147// Upload the image to cloudinary
148
149const imageUploadResponse = await handleCloudinaryUpload({
150
151file: file.filepath,
152
153});
154
155
156
157// Get the image section data
158
159const section = JSON.parse(data.fields[key]);
160
161
162
163// Create a transformation object and append it to the transformation array. The section data contains the x, y, width and height of the image which we need to overlay the image appropriately
164
165transformation.push({
166
167overlay: imageUploadResponse.public_id,
168
169width: section.width,
170
171height: section.height,
172
173x: section.x,
174
175y: section.y,
176
177crop: "fill",
178
179gravity: "north_west",
180
181});
182
183}
184
185
186
187// Create a canvas object
188
189const canvas = createCanvas(layout.width, layout.height);
190
191
192
193// Create a canvas context
194
195const context = canvas.getContext("2d");
196
197
198
199// Fill the canvas with white
200
201context.fillStyle = "#ffffff";
202
203
204
205// Fill the canvas
206
207context.fillRect(0, 0, layout.width, layout.height);
208
209
210
211// Get the canvas image data
212
213const backgroundImageBuffer = canvas.toBuffer("image/png");
214
215
216
217// Upload the background image to cloudinary
218
219const backgroundImageUploadResponse = await handleCloudinaryUpload({
220
221file: `data:image/png;base64,${backgroundImageBuffer.toString("base64")}`,
222
223inFolder: true,
224
225transformation,
226
227});
228
229
230
231// Delete the initially uploaded images from cloudinary
232
233await handleCloudinaryDelete(transformation.map((t) => t.overlay));
234
235
236
237return backgroundImageUploadResponse;
238
239}

A Next.js API route needs to have a default export that is a function that takes in the incoming request object and the outgoing response object. Read about this in the docs.

At the top, we export a custom config object. The custom configuration lets next.js know that we don't want to use the default body-parser. Instead, the body is going to be a stream, and that way we can parse it using formidable. See here. Read more about custom config and API middleware in the next.js docs.

Inside our handler function, we check the incoming HTTP request method. We only want to handle GET and POST methods so we use a switch case statement to check for that and return a 405 - Method Not Allowed response if the request method doesn't match any of our cases.

handleGetRequest calls the handleGetCloudinaryUploads function that we created earlier to get all uploaded resources.

handlePostRequest takes in the incoming request object. It first passes the request object to the parseForm function that we created earlier. parseForm parses the form data. In the form data, we have a layout field and then fields that contain section data(width, height,x,y) for each image uploaded. The form data also contains each uploaded file. We first get the layout data by parsing the stringified JSON. We have a transformation array. This is what cloudinary will use to determine where to overlay our images. Read more here.

We loop through the files that have been uploaded. For each file/image, we upload the image to Cloudinary, then create a transformation object that we'll append to the transformation array. The transformation object contains the overlay field, which is the public id, the width and height of the section where the image will be placed, the x and y coordinates of the section, and then crop and gravity. Read about placing layer overlays on images for more information. For the crop field, we set it to fill so that the image maintains its aspect ratio. You can change this to your liking. Read about it here. For the gravity, we set it to north_west to tell Cloudinary that all x and y values are relative to the top-left corner. In short, the top-left will be the origin(0,0). Read more about it here.

We need a background image where we're going to overlay our already uploaded images/sections. For this we're going to be using the canvas package we installed to create a canvas, fill it with the color white and then get the canvas as an image(Buffer data). We then convert that buffer to a base64 string and upload it to Cloudinary. We also pass the transformation array that we defined. At this point, the transformation array will contain a transformation object for each of our images. We then delete the initially uploaded images since they have been added as overlays to the background image and we no longer need them.

You can also place the overlays using the canvas and just upload the final image to Cloudinary(would be cheaper in terms of Cloudinary tokens/storage) but I wanted to do everything using Cloudinary so we can touch on Cloudinary transformations.


Create a new file called [...id].js under pages/api/images/ folder. Paste the following code inside pages/api/images/[...id].js.

1import { NextApiRequest, NextApiResponse, NextApiHandler } from "next";
2
3import { handleCloudinaryDelete } from "../../../lib/cloudinary";
4
5
6
7/**
8
9* @type {NextApiHandler}
10
11* @param {NextApiRequest} req
12
13* @param {NextApiResponse} res
14
15*/
16
17export default async function handler(req, res) {
18
19let { id } = req.query;
20
21
22
23if (!id) {
24
25res.status(400).json({ error: "Missing id" });
26
27return;
28
29}
30
31
32
33if (Array.isArray(id)) {
34
35id = id.join("/");
36
37}
38
39
40
41switch (req.method) {
42
43case "DELETE": {
44
45try {
46
47const result = await handleDeleteRequest(id);
48
49
50
51return res.status(200).json({ message: "Success", result });
52
53} catch (error) {
54
55console.error(error);
56
57return res.status(400).json({ message: "Error", error });
58
59}
60
61}
62
63
64
65default: {
66
67return res.status(405).json({ message: "Method not allowed" });
68
69}
70
71}
72
73}
74
75
76
77const handleDeleteRequest = async (id) => {
78
79const result = await handleCloudinaryDelete([id]);
80
81
82
83return result;
84
85};

This file handles requests made to the /api/images/:id endoint. This is a dynamic API route. Read about it here. The destructured array syntax for the file name is used to match all routes that come after a dynamic route. For example to handle routes such as /api/images/:id/:anotherId/ or /api/images/:id/someAction/ instead of just /api/images/:id. Read about catching all api routes.

This route only handles DELETE requests. We get the id from the incoming request query and pass that to handleCloudinaryDelete for deletion.

That's it for the backend.


Now for the front end.

Replace the contents of styles/globals.css with the following...

1html,
2
3body {
4
5padding: 0;
6
7margin: 0;
8
9font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
10
11Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
12
13}
14
15
16
17:root {
18
19--color-primary: #0070f3;
20
21--color-danger: #ff0000;
22
23}
24
25
26
27* {
28
29box-sizing: border-box;
30
31}
32
33
34
35img {
36
37object-fit: cover;
38
39}
40
41
42
43a {
44
45color: inherit;
46
47text-decoration: none;
48
49}
50
51
52
53a:hover {
54
55text-decoration: underline;
56
57}
58
59
60
61.danger {
62
63color: var(--color-danger);
64
65}
66
67
68
69.btn {
70
71background-color: var(--color-primary);
72
73border-radius: 2px;
74
75border: none;
76
77color: #fff;
78
79text-transform: uppercase;
80
81padding: 1rem;
82
83font-size: 1rem;
84
85font-weight: 700;
86
87cursor: pointer;
88
89transition: all 0.2s;
90
91min-width: 50px;
92
93}
94
95
96
97.btn.danger {
98
99color: #ffffff;
100
101background-color: var(--color-danger);
102
103}
104
105
106
107.btn:hover:not([disabled]) {
108
109filter: brightness(96%);
110
111box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.2);
112
113}
114
115
116
117.btn:disabled {
118
119opacity: 0.5;
120
121cursor: not-allowed;
122
123}

Some simple CSS.

Create a folder at the root of your project and name it components. Create a new file called Layout.jsx under components folder. Paste the following inside components/Layout.jsx.

1import Head from "next/head";
2
3import Link from "next/link";
4
5
6
7export default function Layout({ children }) {
8
9return (
10
11<div>
12
13<Head>
14
15<title>Photo collage with cloudinary</title>
16
17<meta name="description" content="Photo collage with cloudinary" />
18
19<link rel="icon" href="/favicon.ico" />
20
21</Head>
22
23
24
25<nav>
26
27<Link href="/">
28
29<a>Home</a>
30
31</Link>
32
33
34
35<Link href="/images">
36
37<a>Images</a>
38
39</Link>
40
41</nav>
42
43<main>{children}</main>
44
45<style jsx>{`
46
47nav {
48
49height: 100px;
50
51background-color: var(--color-primary);
52
53display: flex;
54
55align-items: center;
56
57justify-content: center;
58
59gap: 20px;
60
61color: #ffffff;
62
63font-weight: bold;
64
65}
66
67main {
68
69width: 100vw;
70
71min-height: 100vh;
72
73}
74
75`}</style>
76
77</div>
78
79);
80
81}

We're going to be wrapping all our pages in this component so that we have a consistent layout without code duplication.

Create a file called CollageLayout.jsx under components. Paste the following inside components/CollageLayout.jsx.

1import { useState } from "react";
2
3import Image from "next/image";
4
5import { useRouter } from "next/router";
6
7
8
9/**
10
11* @typedef {Object} Collage
12
13* @property {File} file
14
15* @property {Section} section
16
17*/
18
19
20
21/**
22
23* @typedef {Object} Section
24
25* @property {number} width
26
27* @property {number} height
28
29* @property {number} x
30
31* @property {number} y
32
33*/
34
35
36
37/**
38
39*
40
41* @param {{layout:import('../lib/collageLayouts').CollageLayout} props
42
43*/
44
45export default function CollageLayout({ layout }) {
46
47const router = useRouter();
48
49
50
51/**
52
53* @type [{[key:string]: Collage},(images: {[key:string]: Collage}) => void]
54
55*/
56
57const [images, setImages] = useState({});
58
59
60
61const [loading, setLoading] = useState(false);
62
63
64
65async function handleFormSubmit(event) {
66
67event.preventDefault();
68
69
70
71try {
72
73const formData = new FormData();
74
75
76
77formData.append(
78
79"layout",
80
81JSON.stringify({
82
83width: layout.width,
84
85height: layout.height,
86
87})
88
89);
90
91
92
93for (const [key, image] of Object.entries(images)) {
94
95formData.append(key, JSON.stringify(image.section));
96
97formData.append(key, image.file);
98
99}
100
101
102
103setLoading(true);
104
105
106
107const response = await fetch("/api/images", {
108
109method: "POST",
110
111body: formData,
112
113});
114
115
116
117const data = await response.json();
118
119
120
121if (!response.ok) {
122
123throw data;
124
125}
126
127
128
129router.push("/images");
130
131} catch (error) {
132
133console.error(error);
134
135} finally {
136
137setLoading(false);
138
139}
140
141}
142
143
144
145return (
146
147<form className="collage-layout-wrapper" onSubmit={handleFormSubmit}>
148
149<div
150
151className="collage-layout"
152
153style={{
154
155position: "relative",
156
157width: layout.width,
158
159height: layout.height,
160
161}}
162
163>
164
165{layout.sections().map((section, index) => (
166
167<div
168
169className="collage-section"
170
171key={`section-${index}`}
172
173style={{
174
175position: "absolute",
176
177width: section.width,
178
179height: section.height,
180
181left: section.x,
182
183top: section.y,
184
185border: "2px solid black",
186
187boxSizing: "border-box",
188
189backgroundColor: "#ffffff",
190
191}}
192
193>
194
195{images[`layout-${layout.id}-image-${index}`] &&
196
197images[`layout-${layout.id}-image-${index}`].file ? (
198
199<div className="image-preview">
200
201<Image
202
203src={URL.createObjectURL(
204
205images[`layout-${layout.id}-image-${index}`].file
206
207)}
208
209alt={`preview image ${index}`}
210
211layout="fill"
212
213></Image>
214
215</div>
216
217) : (
218
219<div className="file-input">
220
221<label htmlFor={`layout-${layout.id}-image-${index}`}>
222
223Select Image
224
225</label>
226
227
228
229<input
230
231type="file"
232
233name={`layout-${layout.id}-image-${index}`}
234
235id={`layout-${layout.id}-image-${index}`}
236
237accept="image/*"
238
239hidden
240
241onChange={(event) => {
242
243setImages({
244
245...images,
246
247[event.target.name]: {
248
249file: event.target.files[0],
250
251section,
252
253},
254
255});
256
257}}
258
259disabled={loading}
260
261/>
262
263</div>
264
265)}
266
267</div>
268
269))}
270
271</div>
272
273
274
275<button
276
277className="btn"
278
279type="submit"
280
281disabled={
282
283Object.keys(images).length !== layout.sections().length ||
284
285!Object.values(images).every(
286
287(image) => image.file && image.section
288
289) ||
290
291loading
292
293}
294
295>
296
297{loading ? "Uploading ..." : "Upload"}
298
299</button>
300
301
302
303<style jsx>{`
304
305form {
306
307display: flex;
308
309flex-direction: column;
310
311align-items: center;
312
313gap: 20px;
314
315padding: 20px;
316
317background-color: #ececec;
318
319border-radius: 5px;
320
321}
322
323
324
325form button {
326
327width: 100%;
328
329}
330
331
332
333form div.collage-layout div.collage-section div.image-preview {
334
335height: 100%;
336
337width: 100%;
338
339position: relative;
340
341object-fit: cover;
342
343}
344
345
346
347form div.collage-layout div.collage-section div.file-input {
348
349height: 100%;
350
351width: 100%;
352
353}
354
355
356
357form div.collage-layout div.collage-section div.file-input label {
358
359height: 100%;
360
361width: 100%;
362
363display: flex;
364
365align-items: center;
366
367justify-content: center;
368
369}
370
371
372
373form div.collage-layout div.collage-section div.file-input label:hover {
374
375background-color: #ececec;
376
377cursor: pointer;
378
379}
380
381`}</style>
382
383</form>
384
385);
386
387}

This is where the frontend magic happens. The component takes in a layout. i.e. One of those layouts from the layouts array inside lib/collageLayouts.js. The component uses the layout data to create a container of the layout width and height,

1<div
2
3className="collage-layout"
4
5style={{
6
7position: "relative",
8
9width: layout.width,
10
11height: layout.height,
12
13}}
14
15>
16
17...
18
19</div>

and then the sections data to create different sections inside the container

1{layout.sections().map((section, index) => (
2
3<div
4
5className="collage-section"
6
7key={`section-${index}`}
8
9style={{
10
11position: "absolute",
12
13width: section.width,
14
15height: section.height,
16
17left: section.x,
18
19top: section.y,
20
21border: "2px solid black",
22
23boxSizing: "border-box",
24
25backgroundColor: "#ffffff",
26
27}}
28
29></div>))}

These are all inside of a form element. Each different section checks the images state, if an image hasn't been chosen for that section, it displays an input element so the user can select an image. If an image has been selected, it shows a preview of that image.

Let's talk about the images state. images will be an object of the following structure

1// A typescript interface
2
3interface Images {
4
5// Can have any key of type string and value of type object
6
7[key:string]: {
8
9// Object Has a file key that has a value of type File
10
11file: File;
12
13// Object Has a section key with a value of type object
14
15section: {
16
17// Object has a width key with a value of the number
18
19width: number;
20
21// Object has a height key with a value of the number
22
23height: number;
24
25// Object has a x key with a value of the number
26
27x: number;
28
29// Object has a y key with a value of the number
30
31y: number;
32
33}
34
35}
36
37}
38
39
40
41// For example
42
43
44
45const images = {
46
47"layout-1-image-0":{
48
49file: new File(),
50
51section:{
52
53width: 800,
54
55height: 800,
56
57x: 0,
58
59y: 400
60
61}
62
63},
64
65"layout-1-image-1":{
66
67file: new File(),
68
69section:{
70
71width: 600,
72
73height: 700,
74
75x: 300,
76
77y: 100
78
79}
80
81}
82
83}

With that in mind, let's look at the handleFormSubmit. This is triggered when a user clicks on upload. We first create a new form data object.

1const formData = new FormData();

We append the stringified layout data to the form data.

1formData.append(
2
3"layout",
4
5JSON.stringify({
6
7width: layout.width,
8
9height: layout.height,
10
11})
12
13);

Then for every section/image, we append to the formdata the actual image file and also the stringified section data.

1for (const [key, image] of Object.entries(images)) {
2
3formData.append(key, JSON.stringify(image.section));
4
5formData.append(key, image.file);
6
7}

We then post the form data to the /api/images endpoint and navigate to the /images page on success.

At the top of the component, we also have the use of some React hooks such as useState. I'm assuming you are familiar with React and that's why I'm not going into too much detail. You can have a read in the React docs. Read more about useRouter in the Next.js router docs


Paste the following inside pages/index.jsx. If you have pages/index.js instead, you can just paste there or change the extension to .jsx .

TIP: Change your frontend components/pages to .jsx for better intellisense and code completion

1import CollageLayout from "../components/CollageLayout";
2
3import Layout from "../components/Layout";
4
5import { layouts } from "../lib/collageLayouts";
6
7
8
9export default function Home() {
10
11return (
12
13<Layout>
14
15<div className="wrapper">
16
17<h1>Photo collages with Cloudinary + Next.js</h1>
18
19<p>
20
21Identify the desired layout below, select your images and click on
22
23upload
24
25</p>
26
27<p>You can create more layouts in lib/collageLayouts.js</p>
28
29<div className="collage-layouts">
30
31{layouts.map((layout, index) => {
32
33return (
34
35<CollageLayout
36
37key={`layout-${index}`}
38
39layout={layout}
40
41></CollageLayout>
42
43);
44
45})}
46
47</div>
48
49</div>
50
51
52
53<style jsx>{`
54
55div.wrapper {
56
57width: 100%;
58
59min-height: 100vh;
60
61display: flex;
62
63flex-direction: column;
64
65align-items: center;
66
67justify-content: flex-start;
68
69}
70
71
72
73div.wrapper div.collage-layouts {
74
75display: flex;
76
77flex-direction: column;
78
79gap: 50px;
80
81}
82
83`}</style>
84
85</Layout>
86
87);
88
89}

Nothing complicated happening here.


Create a file called images.jsx under pages/ folder. Paste the following inside pages/images.jsx

1import { useCallback, useEffect, useState } from "react";
2
3import Image from "next/image";
4
5import Link from "next/link";
6
7import Layout from "../components/Layout";
8
9
10
11export default function Images() {
12
13const [images, setImages] = useState([]);
14
15
16
17const [loading, setLoading] = useState(false);
18
19
20
21const getImages = useCallback(async function () {
22
23try {
24
25setLoading(true);
26
27
28
29const response = await fetch("/api/images", {
30
31method: "GET",
32
33});
34
35
36
37const data = await response.json();
38
39
40
41if (!response.ok) {
42
43throw data;
44
45}
46
47
48
49setImages(data.result.resources);
50
51} catch (error) {
52
53console.error(error);
54
55} finally {
56
57setLoading(false);
58
59}
60
61}, []);
62
63
64
65useEffect(() => {
66
67getImages();
68
69}, [getImages]);
70
71
72
73const handleDownloadResource = async (url) => {
74
75try {
76
77setLoading(true);
78
79
80
81console.log(url);
82
83
84
85const response = await fetch(url, {});
86
87
88
89if (response.ok) {
90
91const blob = await response.blob();
92
93
94
95const fileUrl = URL.createObjectURL(blob);
96
97
98
99const a = document.createElement("a");
100
101a.href = fileUrl;
102
103
104
105a.download = `photo-collage.${url.split(".").at(-1)}`;
106
107document.body.appendChild(a);
108
109a.click();
110
111a.remove();
112
113return;
114
115}
116
117
118
119throw await response.json();
120
121} catch (error) {
122
123// TODO: Show error message to the user
124
125console.error(error);
126
127} finally {
128
129setLoading(false);
130
131}
132
133};
134
135
136
137const handleDelete = async (id) => {
138
139try {
140
141setLoading(true);
142
143
144
145const response = await fetch(`/api/images/${id}`, {
146
147method: "DELETE",
148
149});
150
151
152
153const data = await response.json();
154
155
156
157if (!response.ok) {
158
159throw data;
160
161}
162
163
164
165getImages();
166
167} catch (error) {
168
169} finally {
170
171setLoading(false);
172
173}
174
175};
176
177
178
179return (
180
181<Layout>
182
183{images.length > 0 ? (
184
185<div className="wrapper">
186
187<div className="images-wrapper">
188
189{images.map((image) => {
190
191return (
192
193<div className="image-wrapper" key={image.public_id}>
194
195<div className="image">
196
197<Image
198
199src={image.secure_url}
200
201width={image.width}
202
203height={image.height}
204
205layout="responsive"
206
207alt={image.secure_url}
208
209></Image>
210
211</div>
212
213<div className="actions">
214
215<button
216
217className="btn"
218
219disabled={loading}
220
221onClick={() => {
222
223handleDownloadResource(image.secure_url);
224
225}}
226
227>
228
229Download
230
231</button>
232
233<button
234
235className="btn danger"
236
237disabled={loading}
238
239onClick={() => {
240
241handleDelete(image.public_id);
242
243}}
244
245>
246
247Delete
248
249</button>
250
251</div>
252
253</div>
254
255);
256
257})}
258
259</div>
260
261</div>
262
263) : null}
264
265{!loading && images.length === 0 ? (
266
267<div className="no-images">
268
269<b>No Images Yet</b>
270
271<Link href="/">
272
273<a className="btn">Upload some images</a>
274
275</Link>
276
277</div>
278
279) : null}
280
281{loading && images.length === 0 ? (
282
283<div className="loading">
284
285<b>Loading...</b>
286
287</div>
288
289) : null}
290
291<style jsx>{`
292
293div.wrapper {
294
295min-height: 100vh;
296
297background-color: #f4f4f4;
298
299}
300
301
302
303div.wrapper div.images-wrapper {
304
305display: flex;
306
307flex-flow: row wrap;
308
309gap: 10px;
310
311padding: 10px;
312
313}
314
315
316
317div.wrapper div.images-wrapper div.image-wrapper {
318
319flex: 0 0 400px;
320
321display: flex;
322
323flex-flow: column;
324
325}
326
327
328
329div.wrapper div.images-wrapper div.image-wrapper div.image {
330
331background-color: #ffffff;
332
333position: relative;
334
335width: 100%;
336
337}
338
339
340
341div.wrapper div.images-wrapper div.image-wrapper div.actions {
342
343background-color: #ffffff;
344
345padding: 10px;
346
347display: flex;
348
349flex-flow: row wrap;
350
351gap: 10px;
352
353}
354
355
356
357div.loading,
358
359div.no-images {
360
361height: 100vh;
362
363display: flex;
364
365align-items: center;
366
367justify-content: center;
368
369flex-flow: column;
370
371gap: 10px;
372
373}
374
375`}</style>
376
377</Layout>
378
379);
380
381}

This component uses the React useEffect hook, to run the memoized getImages function. Read more about useEffect and useCallback.

getImages makes a GET request to the /api/images endpoint to get all uploaded images.

handleDelete makes a DELETE request to /api/images/:id to delete the resource/image with the given id.

For the body of the component, we just show the images in a flexbox container along with a delete and download button.


One more thing we need to do. We need to add the Cloudinary domain to our next.js configuration.

Modify next.config.js and add the following.

1module.exports = {
2
3// ...others
4
5images: {
6
7domains: ["res.cloudinary.com"],
8
9},
10
11};

This is to enable Next.js to optimize the images that we're showing using the Image component. Read more about this here.

And that's it. You can now run your application!

1npm run dev

You can find the full source code on my Github. If you'd like a challenge or some homework, try and figure out how you can add a border to your layouts.

Eugene Musebe

Software Developer

I’m a full-stack software developer, content creator, and tech community builder based in Nairobi, Kenya. I am addicted to learning new technologies and loves working with like-minded people.