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_NAME23API_KEY=YOUR_API_KEY45API_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/**23* @typedef {Object} CollageLayout45* @property {number} id67* @property {number} width89* @property {number} height1011* @property {() => CollageLayout[]} sections1213*/14151617/**1819* @typedef {Object} CollageSection2021* @property {number} width2223* @property {number} height2425* @property {number} x2627* @property {number} y2829*/30313233/**3435* Pre-defined layouts. You can add more layouts here. Make sure each has a unique id.3637*3839* @type {CollageLayout[]}4041*/4243export const layouts = [4445{4647id: 1,4849width: 800,5051height: 800,5253sections: function () {5455return [5657{5859width: this.width * 0.5,6061height: this.height * 0.4,6263x: 0,6465y: 0,6667},6869{7071width: this.width * 0.5,7273height: this.height,7475x: this.width * 0.5,7677y: 0,7879},8081{8283width: this.width * 0.5,8485height: this.height * 0.6,8687x: 0,8889y: this.height * 0.4,9091},9293];9495},9697},9899{100101id: 2,102103width: 800,104105height: 400,106107sections: function () {108109return [110111{112113width: this.width * 0.5,114115height: this.height,116117x: 0,118119y: 0,120121},122123{124125width: this.width * 0.5,126127height: this.height,128129x: this.width * 0.5,130131y: 0,132133},134135];136137},138139},140141{142143id: 3,144145width: 800,146147height: 800,148149sections: function () {150151return [152153{154155width: this.width * 0.5,156157height: this.height * 0.5,158159x: 0,160161y: 0,162163},164165{166167width: this.width * 0.5,168169height: this.height * 0.5,170171x: this.width * 0.5,172173y: 0,174175},176177{178179width: this.width,180181height: this.height * 0.5,182183x: 0,184185y: this.height * 0.5,186187},188189];190191},192193},194195{196197id: 4,198199width: 800,200201height: 800,202203sections: function () {204205return [206207{208209width: this.width,210211height: this.height * 0.5,212213x: 0,214215y: 0,216217},218219{220221width: this.width * 0.5,222223height: this.height * 0.5,224225x: 0,226227y: this.height * 0.5,228229},230231{232233width: this.width * 0.5,234235height: this.height * 0.5,236237x: this.width * 0.5,238239y: this.height * 0.5,240241},242243];244245},246247},248249{250251id: 5,252253width: 800,254255height: 600,256257sections: function () {258259return [260261{262263width: this.width * 0.4,264265height: this.height,266267x: 0,268269y: 0,270271},272273{274275width: this.width * 0.6,276277height: this.height * 0.5,278279x: this.width * 0.4,280281y: 0,282283},284285{286287width: this.width * 0.6,288289height: this.height * 0.5,290291x: this.width * 0.4,292293y: this.height * 0.5,294295},296297];298299},300301},302303{304305id: 6,306307width: 800,308309height: 800,310311sections: function () {312313return [314315{316317width: this.width,318319height: this.height * 0.25,320321x: 0,322323y: 0,324325},326327{328329width: this.width,330331height: this.height * 0.25,332333x: 0,334335y: this.height * 0.25,336337},338339{340341width: this.width,342343height: this.height * 0.25,344345x: 0,346347y: this.height * 0.5,348349},350351{352353width: this.width,354355height: this.height * 0.25,356357x: 0,358359y: this.height * 0.75,360361},362363];364365},366367},368369];
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";2345/**67* Parses the incoming form data.89*1011* @param {NextApiRequest} req The incoming request object1213* @returns {Promise<{fields:Fields;files:Files;}>} The parsed form data1415*/1617export const parseForm = (req) => {1819return new Promise((resolve, reject) => {2021const form = new IncomingForm({ keepExtensions: true, multiples: true });22232425form.parse(req, (error, fields, files) => {2627if (error) {2829return reject(error);3031}32333435return resolve({ fields, files });3637});3839});4041};
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 cloudinary23import { v2 as cloudinary, TransformationOptions } from "cloudinary";4567// Initialize the SDK with cloud_name, api_key, and api_secret89cloudinary.config({1011cloud_name: process.env.CLOUD_NAME,1213api_key: process.env.API_KEY,1415api_secret: process.env.API_SECRET,1617});18192021const CLOUDINARY_FOLDER_NAME = "photo-collage/";22232425/**2627* Get cloudinary uploads2829* @param {string} folder Folder name3031* @returns {Promise}3233*/3435export const handleGetCloudinaryUploads = (folder = CLOUDINARY_FOLDER_NAME) => {3637return cloudinary.api.resources({3839type: "upload",4041prefix: folder,4243resource_type: "image",4445});4647};48495051/**5253* @typedef {Object} Resource5455* @property {string | Buffer} file5657* @property {string} publicId5859* @property {boolean} inFolder6061* @property {string} folder6263* @property {TransformationOptions} transformation6465*6667*/68697071/**7273* Uploads an image to cloudinary and returns the upload result7475*7677* @param {Resource} resource7879*/8081export const handleCloudinaryUpload = ({8283file,8485publicId,8687transformation,8889folder = CLOUDINARY_FOLDER_NAME,9091inFolder = false,9293}) => {9495return cloudinary.uploader.upload(file, {9697// Folder to store image in9899folder: inFolder ? folder : null,100101// Public id of image.102103public_id: publicId,104105// Type of resource106107resource_type: "auto",108109// Transformation to apply to the video110111transformation,112113});114115};116117118119/**120121* Deletes resources from cloudinary. Takes in an array of public ids122123* @param {string[]} ids124125*/126127export const handleCloudinaryDelete = (ids) => {128129return cloudinary.api.delete_resources(ids, {130131resource_type: "image",132133});134135};
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";23import { createCanvas } from "canvas";45import { parseForm } from "../../../lib/parse-form";67import {89handleCloudinaryDelete,1011handleCloudinaryUpload,1213handleGetCloudinaryUploads,1415} from "../../../lib/cloudinary";16171819export const config = {2021api: {2223bodyParser: false,2425},2627};28293031/**3233*3435* @param {NextApiRequest} req3637* @param {NextApiResponse} res3839*/4041export default async function handler(req, res) {4243const { method } = req;44454647switch (method) {4849case "GET": {5051try {5253const result = await handleGetRequest();54555657return res.status(200).json({ message: "Success", result });5859} catch (error) {6061console.error(error);6263return res.status(400).json({ message: "Error", error });6465}6667}68697071case "POST": {7273try {7475const result = await handlePostRequest(req);76777879return res.status(201).json({ message: "Success", result });8081} catch (error) {8283console.error(error);8485return res.status(400).json({ message: "Error", error });8687}8889}90919293default: {9495return res.status(405).json({ message: "Method not allowed" });9697}9899}100101}102103104105async function handleGetRequest() {106107return handleGetCloudinaryUploads();108109}110111112113/**114115*116117* @param {NextApiRequest} req118119*/120121async function handlePostRequest(req) {122123console.log("post received");124125// Get the form data using the parseForm function126127const data = await parseForm(req);128129130131// Get the layout data132133const layout = JSON.parse(data.fields["layout"]);134135136137// The transformation object that will be passed to cloudinary to overlay the different images138139const transformation = [];140141142143// Loop through the uploaded images, upload each to cloudinary and populate the transformation array144145for (const [key, file] of Object.entries(data.files)) {146147// Upload the image to cloudinary148149const imageUploadResponse = await handleCloudinaryUpload({150151file: file.filepath,152153});154155156157// Get the image section data158159const section = JSON.parse(data.fields[key]);160161162163// 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 appropriately164165transformation.push({166167overlay: imageUploadResponse.public_id,168169width: section.width,170171height: section.height,172173x: section.x,174175y: section.y,176177crop: "fill",178179gravity: "north_west",180181});182183}184185186187// Create a canvas object188189const canvas = createCanvas(layout.width, layout.height);190191192193// Create a canvas context194195const context = canvas.getContext("2d");196197198199// Fill the canvas with white200201context.fillStyle = "#ffffff";202203204205// Fill the canvas206207context.fillRect(0, 0, layout.width, layout.height);208209210211// Get the canvas image data212213const backgroundImageBuffer = canvas.toBuffer("image/png");214215216217// Upload the background image to cloudinary218219const backgroundImageUploadResponse = await handleCloudinaryUpload({220221file: `data:image/png;base64,${backgroundImageBuffer.toString("base64")}`,222223inFolder: true,224225transformation,226227});228229230231// Delete the initially uploaded images from cloudinary232233await handleCloudinaryDelete(transformation.map((t) => t.overlay));234235236237return backgroundImageUploadResponse;238239}
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";23import { handleCloudinaryDelete } from "../../../lib/cloudinary";4567/**89* @type {NextApiHandler}1011* @param {NextApiRequest} req1213* @param {NextApiResponse} res1415*/1617export default async function handler(req, res) {1819let { id } = req.query;20212223if (!id) {2425res.status(400).json({ error: "Missing id" });2627return;2829}30313233if (Array.isArray(id)) {3435id = id.join("/");3637}38394041switch (req.method) {4243case "DELETE": {4445try {4647const result = await handleDeleteRequest(id);48495051return res.status(200).json({ message: "Success", result });5253} catch (error) {5455console.error(error);5657return res.status(400).json({ message: "Error", error });5859}6061}62636465default: {6667return res.status(405).json({ message: "Method not allowed" });6869}7071}7273}74757677const handleDeleteRequest = async (id) => {7879const result = await handleCloudinaryDelete([id]);80818283return result;8485};
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,23body {45padding: 0;67margin: 0;89font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,1011Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;1213}14151617:root {1819--color-primary: #0070f3;2021--color-danger: #ff0000;2223}24252627* {2829box-sizing: border-box;3031}32333435img {3637object-fit: cover;3839}40414243a {4445color: inherit;4647text-decoration: none;4849}50515253a:hover {5455text-decoration: underline;5657}58596061.danger {6263color: var(--color-danger);6465}66676869.btn {7071background-color: var(--color-primary);7273border-radius: 2px;7475border: none;7677color: #fff;7879text-transform: uppercase;8081padding: 1rem;8283font-size: 1rem;8485font-weight: 700;8687cursor: pointer;8889transition: all 0.2s;9091min-width: 50px;9293}94959697.btn.danger {9899color: #ffffff;100101background-color: var(--color-danger);102103}104105106107.btn:hover:not([disabled]) {108109filter: brightness(96%);110111box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.2);112113}114115116117.btn:disabled {118119opacity: 0.5;120121cursor: not-allowed;122123}
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";23import Link from "next/link";4567export default function Layout({ children }) {89return (1011<div>1213<Head>1415<title>Photo collage with cloudinary</title>1617<meta name="description" content="Photo collage with cloudinary" />1819<link rel="icon" href="/favicon.ico" />2021</Head>22232425<nav>2627<Link href="/">2829<a>Home</a>3031</Link>32333435<Link href="/images">3637<a>Images</a>3839</Link>4041</nav>4243<main>{children}</main>4445<style jsx>{`4647nav {4849height: 100px;5051background-color: var(--color-primary);5253display: flex;5455align-items: center;5657justify-content: center;5859gap: 20px;6061color: #ffffff;6263font-weight: bold;6465}6667main {6869width: 100vw;7071min-height: 100vh;7273}7475`}</style>7677</div>7879);8081}
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";23import Image from "next/image";45import { useRouter } from "next/router";6789/**1011* @typedef {Object} Collage1213* @property {File} file1415* @property {Section} section1617*/18192021/**2223* @typedef {Object} Section2425* @property {number} width2627* @property {number} height2829* @property {number} x3031* @property {number} y3233*/34353637/**3839*4041* @param {{layout:import('../lib/collageLayouts').CollageLayout} props4243*/4445export default function CollageLayout({ layout }) {4647const router = useRouter();48495051/**5253* @type [{[key:string]: Collage},(images: {[key:string]: Collage}) => void]5455*/5657const [images, setImages] = useState({});58596061const [loading, setLoading] = useState(false);62636465async function handleFormSubmit(event) {6667event.preventDefault();68697071try {7273const formData = new FormData();74757677formData.append(7879"layout",8081JSON.stringify({8283width: layout.width,8485height: layout.height,8687})8889);90919293for (const [key, image] of Object.entries(images)) {9495formData.append(key, JSON.stringify(image.section));9697formData.append(key, image.file);9899}100101102103setLoading(true);104105106107const response = await fetch("/api/images", {108109method: "POST",110111body: formData,112113});114115116117const data = await response.json();118119120121if (!response.ok) {122123throw data;124125}126127128129router.push("/images");130131} catch (error) {132133console.error(error);134135} finally {136137setLoading(false);138139}140141}142143144145return (146147<form className="collage-layout-wrapper" onSubmit={handleFormSubmit}>148149<div150151className="collage-layout"152153style={{154155position: "relative",156157width: layout.width,158159height: layout.height,160161}}162163>164165{layout.sections().map((section, index) => (166167<div168169className="collage-section"170171key={`section-${index}`}172173style={{174175position: "absolute",176177width: section.width,178179height: section.height,180181left: section.x,182183top: section.y,184185border: "2px solid black",186187boxSizing: "border-box",188189backgroundColor: "#ffffff",190191}}192193>194195{images[`layout-${layout.id}-image-${index}`] &&196197images[`layout-${layout.id}-image-${index}`].file ? (198199<div className="image-preview">200201<Image202203src={URL.createObjectURL(204205images[`layout-${layout.id}-image-${index}`].file206207)}208209alt={`preview image ${index}`}210211layout="fill"212213></Image>214215</div>216217) : (218219<div className="file-input">220221<label htmlFor={`layout-${layout.id}-image-${index}`}>222223Select Image224225</label>226227228229<input230231type="file"232233name={`layout-${layout.id}-image-${index}`}234235id={`layout-${layout.id}-image-${index}`}236237accept="image/*"238239hidden240241onChange={(event) => {242243setImages({244245...images,246247[event.target.name]: {248249file: event.target.files[0],250251section,252253},254255});256257}}258259disabled={loading}260261/>262263</div>264265)}266267</div>268269))}270271</div>272273274275<button276277className="btn"278279type="submit"280281disabled={282283Object.keys(images).length !== layout.sections().length ||284285!Object.values(images).every(286287(image) => image.file && image.section288289) ||290291loading292293}294295>296297{loading ? "Uploading ..." : "Upload"}298299</button>300301302303<style jsx>{`304305form {306307display: flex;308309flex-direction: column;310311align-items: center;312313gap: 20px;314315padding: 20px;316317background-color: #ececec;318319border-radius: 5px;320321}322323324325form button {326327width: 100%;328329}330331332333form div.collage-layout div.collage-section div.image-preview {334335height: 100%;336337width: 100%;338339position: relative;340341object-fit: cover;342343}344345346347form div.collage-layout div.collage-section div.file-input {348349height: 100%;350351width: 100%;352353}354355356357form div.collage-layout div.collage-section div.file-input label {358359height: 100%;360361width: 100%;362363display: flex;364365align-items: center;366367justify-content: center;368369}370371372373form div.collage-layout div.collage-section div.file-input label:hover {374375background-color: #ececec;376377cursor: pointer;378379}380381`}</style>382383</form>384385);386387}
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<div23className="collage-layout"45style={{67position: "relative",89width: layout.width,1011height: layout.height,1213}}1415>1617...1819</div>
and then the sections data to create different sections inside the container
1{layout.sections().map((section, index) => (23<div45className="collage-section"67key={`section-${index}`}89style={{1011position: "absolute",1213width: section.width,1415height: section.height,1617left: section.x,1819top: section.y,2021border: "2px solid black",2223boxSizing: "border-box",2425backgroundColor: "#ffffff",2627}}2829></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 interface23interface Images {45// Can have any key of type string and value of type object67[key:string]: {89// Object Has a file key that has a value of type File1011file: File;1213// Object Has a section key with a value of type object1415section: {1617// Object has a width key with a value of the number1819width: number;2021// Object has a height key with a value of the number2223height: number;2425// Object has a x key with a value of the number2627x: number;2829// Object has a y key with a value of the number3031y: number;3233}3435}3637}38394041// For example42434445const images = {4647"layout-1-image-0":{4849file: new File(),5051section:{5253width: 800,5455height: 800,5657x: 0,5859y: 4006061}6263},6465"layout-1-image-1":{6667file: new File(),6869section:{7071width: 600,7273height: 700,7475x: 300,7677y: 1007879}8081}8283}
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(23"layout",45JSON.stringify({67width: layout.width,89height: layout.height,1011})1213);
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)) {23formData.append(key, JSON.stringify(image.section));45formData.append(key, image.file);67}
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";23import Layout from "../components/Layout";45import { layouts } from "../lib/collageLayouts";6789export default function Home() {1011return (1213<Layout>1415<div className="wrapper">1617<h1>Photo collages with Cloudinary + Next.js</h1>1819<p>2021Identify the desired layout below, select your images and click on2223upload2425</p>2627<p>You can create more layouts in lib/collageLayouts.js</p>2829<div className="collage-layouts">3031{layouts.map((layout, index) => {3233return (3435<CollageLayout3637key={`layout-${index}`}3839layout={layout}4041></CollageLayout>4243);4445})}4647</div>4849</div>50515253<style jsx>{`5455div.wrapper {5657width: 100%;5859min-height: 100vh;6061display: flex;6263flex-direction: column;6465align-items: center;6667justify-content: flex-start;6869}70717273div.wrapper div.collage-layouts {7475display: flex;7677flex-direction: column;7879gap: 50px;8081}8283`}</style>8485</Layout>8687);8889}
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";23import Image from "next/image";45import Link from "next/link";67import Layout from "../components/Layout";891011export default function Images() {1213const [images, setImages] = useState([]);14151617const [loading, setLoading] = useState(false);18192021const getImages = useCallback(async function () {2223try {2425setLoading(true);26272829const response = await fetch("/api/images", {3031method: "GET",3233});34353637const data = await response.json();38394041if (!response.ok) {4243throw data;4445}46474849setImages(data.result.resources);5051} catch (error) {5253console.error(error);5455} finally {5657setLoading(false);5859}6061}, []);62636465useEffect(() => {6667getImages();6869}, [getImages]);70717273const handleDownloadResource = async (url) => {7475try {7677setLoading(true);78798081console.log(url);82838485const response = await fetch(url, {});86878889if (response.ok) {9091const blob = await response.blob();92939495const fileUrl = URL.createObjectURL(blob);96979899const a = document.createElement("a");100101a.href = fileUrl;102103104105a.download = `photo-collage.${url.split(".").at(-1)}`;106107document.body.appendChild(a);108109a.click();110111a.remove();112113return;114115}116117118119throw await response.json();120121} catch (error) {122123// TODO: Show error message to the user124125console.error(error);126127} finally {128129setLoading(false);130131}132133};134135136137const handleDelete = async (id) => {138139try {140141setLoading(true);142143144145const response = await fetch(`/api/images/${id}`, {146147method: "DELETE",148149});150151152153const data = await response.json();154155156157if (!response.ok) {158159throw data;160161}162163164165getImages();166167} catch (error) {168169} finally {170171setLoading(false);172173}174175};176177178179return (180181<Layout>182183{images.length > 0 ? (184185<div className="wrapper">186187<div className="images-wrapper">188189{images.map((image) => {190191return (192193<div className="image-wrapper" key={image.public_id}>194195<div className="image">196197<Image198199src={image.secure_url}200201width={image.width}202203height={image.height}204205layout="responsive"206207alt={image.secure_url}208209></Image>210211</div>212213<div className="actions">214215<button216217className="btn"218219disabled={loading}220221onClick={() => {222223handleDownloadResource(image.secure_url);224225}}226227>228229Download230231</button>232233<button234235className="btn danger"236237disabled={loading}238239onClick={() => {240241handleDelete(image.public_id);242243}}244245>246247Delete248249</button>250251</div>252253</div>254255);256257})}258259</div>260261</div>262263) : null}264265{!loading && images.length === 0 ? (266267<div className="no-images">268269<b>No Images Yet</b>270271<Link href="/">272273<a className="btn">Upload some images</a>274275</Link>276277</div>278279) : null}280281{loading && images.length === 0 ? (282283<div className="loading">284285<b>Loading...</b>286287</div>288289) : null}290291<style jsx>{`292293div.wrapper {294295min-height: 100vh;296297background-color: #f4f4f4;298299}300301302303div.wrapper div.images-wrapper {304305display: flex;306307flex-flow: row wrap;308309gap: 10px;310311padding: 10px;312313}314315316317div.wrapper div.images-wrapper div.image-wrapper {318319flex: 0 0 400px;320321display: flex;322323flex-flow: column;324325}326327328329div.wrapper div.images-wrapper div.image-wrapper div.image {330331background-color: #ffffff;332333position: relative;334335width: 100%;336337}338339340341div.wrapper div.images-wrapper div.image-wrapper div.actions {342343background-color: #ffffff;344345padding: 10px;346347display: flex;348349flex-flow: row wrap;350351gap: 10px;352353}354355356357div.loading,358359div.no-images {360361height: 100vh;362363display: flex;364365align-items: center;366367justify-content: center;368369flex-flow: column;370371gap: 10px;372373}374375`}</style>376377</Layout>378379);380381}
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 = {23// ...others45images: {67domains: ["res.cloudinary.com"],89},1011};
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.