Managing a website's content is always challenging when developing a modern web application. You can manage the content via a database or a hard-coded JSON file. But, when creating large-scale projects, managing files and databases becomes complex. In these cases, using a Content Management System (CMS) is one of the simple and efficient methods.
A CMS is software used to create and manage content with a team. There are many third-party CMS providers, such as Sanity and Strapi. However, someone once said, "If you want something done right, do it yourself."
In this article, I will teach you how to create your own CMS using the latest web development technologies. You'll learn to use a database and Cloudinary to manage your web app's content and digital assets.
Here’s the source code and the final demo if you want to dive directly into the code.
Prerequisites
Before diving into this tutorial, you should:
- have a solid understanding of HTML and CSS
- know JavaScript's ES6 syntax
- understand React and how it works
What You Will Build
You will build a CMS for managing the content of an Ecommerce website. There are various types of content management systems. In this tutorial, we'll create a headless CMS with a back-end system connecting to a database for managing content using a web interface. You will expose the content for the front end via API endpoints and use the data however you like.
Technologies You Will Use
- Next.js — a JavaScript framework for building FullStack Jamstack applications. It extends the capabilities of React for writing serverless code
- Tailwind CSS — a utility-first CSS framework that uses classes to style the markup
- Xata — a serverless database that lets you create Jamstack applications without worrying about deployment or scaling issues
- Cloudinary — a platform for managing media assets, such as images
You'll need Cloudinary, Xata, and Netlify accounts to follow along. These services provide a generous free tier you can use for this project.
Getting Started
To help you out, I created a starter codesandbox; fork it and start coding.
Run the following command to start the local development environment.
yarn create next-app my-ecommerce-cms -e https://github.com/giridhar7632/ecommerce-cms-starter
# or
npx create-next-app my-ecommerce-cms -e https://github.com/giridhar7632/ecommerce-cms-starter
The starter code includes all essential dependencies, such as Tailwind CSS, Xata, and others. It also contains several ready-made frontend components styled using Tailwind CSS.
Navigate to the project directory and start your development server after installing the dependencies:
cd my-ecommerce-app
yarn dev
Now you will be able to see the app running at http://localhost:3000. You can also see the preview inside your codesandbox.
Starter Code
I already created the UI of the application in the starter code to make the process simpler. The main things inside the project are:
-
The
**/**
**pages**
directory: Next.js allows file system-based routing. It considers anything inside this directory as a route and the files inside/pages/api
as API endpoints -
The
**/components**
directory: If you are familiar with React, you may already know that everything is a reusable component. This directory contains components such as layouts, forms, and everything required. Take a look inside the folders to understand the structure -
The
**/pages/products.js**
file: The route for displaying the products. You can find the components used here inside the/components/Products
folder -
The
**/pages/product**
directory: You can create dynamic routes for generating static pages using Next.js. You can define the route using the bracket syntax ([slug]
) to make it dynamic. You can find the file[id].js
inside this folder. So, you can get theid
as a parameter inside that route
Let's configure the database and add API endpoints to make the UI interactive.
Connecting to Database
Xata is the serverless database that you will use to develop this CMS. Using Xata, you can store all the data inside a fully configured database without worrying about deployment and scaling.
Xata provides a CLI tool, @xata.io/cli
, for creating and managing databases directly from the terminal.
Run the command below to install the Xata CLI globally and use it in your project.
yarn add -g @xata.io/cli
# or
npm i -g @xata.io/cli
After the installation, use the below command to generate an API key for Xata.
xata auth login
Xata will prompt you to select from the following options:
For this project, select the Create a new API key in the browser; give the API key a name when the browser window pops up.
Workspaces symbolize your team, and they are the starting point of Xata where databases are created. You can create one once you log in to your account. The data within the database is organized in the form of tables, which have columns that form a strict schema in the database.
Creating the Table
To add products to the database, begin by constructing a table and its schema for data structuring. The products table will have a few columns, such as:
-
id
— an automated column maintained by Xata -
name
— a string containing the product's name -
description
— a string with a description of the product -
price
— a number containing the product value -
stock
— the number of items remaining -
thumbnail
— the image URL of the product -
media
— multiple URLs to the media of the product
In Xata, you can construct a table using either the browser or your CLI. Now, let's build the table with the CLI. The database schema for this course is already inside the schema.json
file of the starter code.
/schema.json
{
"formatVersion": "1",
"tables": [
{
"name": "products",
"columns": [
{
"name": "name",
"type": "string"
},
{
"name": "description",
"type": "text"
},
{
"name": "price",
"type": "float"
},
{
"name": "stock",
"type": "int"
},
{
"name": "thumbnail",
"type": "string"
},
{
"name": "media",
"type": "multiple"
}
]
}
]
}
Now, execute the following command to populate the database with the given schema.
xata init --schema=schema.json --codegen=utils/xata.js
To create a new database, you must first answer several questions in the terminal. Then the CLI reads the schema from the file and creates a database. It also generates a /utils/xata.js
file for using the Xata client. You will use this client to communicate with Xata.
Use the xata random-data
command to generate some random data inside your database. Open your Xata workspace inside the browser to see your database in action.
Creating Product Data
Now, let's develop API endpoints for interacting with product data. Inside the /pages/api
directory, add a folder called products
and file called, createProduct.js
. You will use this file to handle the POST request for creating a product within the database.
/pages/api/products/createProduct.js
import { getXataClient } from "../../../utils/xata";
const xata = getXataClient();
const handler = async (req, res) => {
// create method is used to create records in database
try {
await xata.db.products.create({ ...req.body });
res.json({ message: "Success 😁" });
} catch (error) {
res.status(500).json({ message: error.message });
}
};
export default handler;
The Xata Client
provides methods for accessing the databases; you can use the names of the table to perform operations like xata.db.<table-name>
for example. You can also create records inside a database using the create()
method of the table.
You can see the form inside /components/ProductForm
for adding the required product data. I used react-hook-form
to handle the form data in multiple steps.
Uploading Images to Cloudinary
Before adding new product to database, you will first upload the image to Cloudinary and store the image link with product data. Once you have a Cloudinary account, follow the below steps to enable image upload.
There are two ways of uploading media to Cloudinary:
- Signed presets
- Unsigned presets.
Create an unsigned preset to allow users to upload images to your cloud.
Go to your Cloudinary Dashboard and navigate to
Settings > Upload > Add upload preset
.
Configure the Name, Signing Mode, and Folder in the upload preset. Adding a folder is optional, but I recommend you put all your uploaded images in one place. You can learn more about Cloudinary upload presets in their documentation.
Once saved, go to the previous page, where you can find the new unsigned preset.
With your new preset, you can now upload the images to Cloudinary from the frontend. The ThumbnailUpload.js
file inside /components/ProductForm
directory will handle the image upload and add the image URL to the product data. Add the following code inside the file.
/components/ProductForm/ThumbnailUpload.js
import React, { useState } from "react";
import Button from "../common/Button";
const ThumbnailUpload = ({ defaultValue, setValue }) => {
const [imageSrc, setImageSrc] = useState(defaultValue);
const [loading, setLoading] = useState(false);
const [uploadData, setUploadData] = useState();
const handleOnChange = (changeEvent) => {
// ...
};
const handleUpload = async (uploadEvent) => {
uploadEvent.preventDefault();
setLoading(true);
const form = uploadEvent.currentTarget;
const fileInput = Array.from(form.elements).find(
({ name }) => name === "file"
);
try {
const formData = new FormData();
// specifying cloudinary upload preset
formData.append("upload_preset", "vnqoc9iz");
for (const file of fileInput.files) {
formData.append("file", file);
}
const res = await fetch(
"https://api.cloudinary.com/v1_1/scrapbook/image/upload",
{
method: "POST",
body: formData,
}
);
const data = await res.json();
setImageSrc(data.secure_url);
// adding the thumbnail URL to te main form data
setValue("thumbnail", data.secure_url);
setUploadData(data);
} catch (error) {
console.log(error);
}
setLoading(false);
};
return (
<form onSubmit={handleSubmit}>
// ...
</form>
);
};
export default ThumbnailUpload;
The code above does the following:
- extracts the file from the form input, sends a POST request to the Cloudinary API along with Form data, and returns the public URL of the file
- uses the
setValue
method ofreact-hook-form
to add the image URL to the product form data - And also updates the components' state to make UI interactive
Similarly, you can add multiple image uploads using Promise.all
method to handle the product media.
/components/ProductForm/MediaUpload.js
// ...
const MediaUpload = ({ defaultValues = [], setValue }) => {
const [imageSrc, setImageSrc] = useState([...defaultValues]);
const [loading, setLoading] = useState(false);
const [uploadedData, setUploadedData] = useState(false);
const handleOnChange = (changeEvent) => {
// ...
};
const handleUpload = async (uploadEvent) => {
uploadEvent.preventDefault();
setLoading(true);
const form = uploadEvent.currentTarget;
const fileInput = Array.from(form.elements).find(
({ name }) => name === "file"
);
try {
// adding upload preset
const files = [];
for (const file of fileInput.files) {
files.push(file);
}
const urls = await Promise.all(
files.map(async (file) => {
const formData = new FormData();
formData.append("file", file);
formData.append("upload_preset", "vnqoc9iz");
const res = await fetch(
"https://api.cloudinary.com/v1_1/scrapbook/image/upload",
{
method: "POST",
body: formData,
}
);
const data = await res.json();
return data.secure_url;
})
);
setImageSrc(urls);
setValue("media", urls);
setUploadedData(true);
} catch (error) {
console.log(error);
}
setLoading(false);
};
return <form onSubmit={handleUpload}>...</form>;
};
export default MediaUpload;
Now, you should add the product to the database by sending a POST request to the endpoint you created.
/components/Product/AddProduct.js
import { Close } from "../common/icons/Close";
import ProductForm from "../ProductForm";
const AddProduct = ({ props }) => {
const [isOpen, setIsOpen] = useState(false);
const handleClose = () => setIsOpen(false);
const handleOpen = () => setIsOpen(true);
const onFormSubmit = async (data) => {
try {
await fetch(`${baseUrl}/api/products/createProduct`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
}).then(() => {
handleClose();
window.location.reload();
});
} catch (error) {
console.log(error);
}
};
return (
// ...
);
};
Now, try to add new products using the form. You can see the files uploaded to your Cloudinary and records created in Xata.
Querying the Product Data
For displaying all the products, you will use client-side rendering. To show the details of each product, let's use server-side rendering with dynamic routing. Create an API endpoint getProducts.js
for getting all the product data.
⚠️ Warning: It is advised not to use Xata in client-side programming. It may reveal your API key while retrieving data from the browser. So, we'll get all the data from the Xata inside API routes and deliver it to the client.
/pages/api/products/getProducts.js
import { getXataClient } from "../../../utils/xata";
const xata = getXataClient();
const handler = async (req, res) => {
// getMany or getAll method can be used to create records in database
try {
const data = await xata.db.products.getAll();
res.json({ message: "Success 😁", data });
} catch (error) {
res.status(500).json({ message: error.message, data: [] });
}
};
export default handler;
Here, you used the getAll
method of the table to fetch all the records at one time. Refer to the Xata docs for more ways of fetching data from a table.
Inside the /pages/Products.js
file, add the following code for fetching the data from the API.
/pages/Products.js
import { useEffect, useState } from "react";
// ...
function Products() {
const [loading, setLoading] = useState(false);
const [products, setProducts] = useState([]);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const res = await fetch(`/api/products/getProducts`);
const { data } = await res.json();
setProducts(data);
} catch (error) {
console.log(error);
}
setLoading(false);
};
fetchData();
}, []);
return (
// ...
);
}
// ...
In the above code, you are fetching the data inside the useEffect
hook from the API you created before. Save your code and head over to the browser. You can see the product data displayed in the form of a table.
You will be redirected to the /product/<some-product-id>
when you click on the details. As of now, you'll see an empty page; let's move on to create the add the data.
Generating Static Pages for Product Details
You can find the /product
folder inside the /pages
directory. As mentioned, I added the [id].js
file to generate dynamic routes. You will access the product's id using the parameters of the route. Refer to Next.js documentation for more information on dynamic.
In the following code, first, you are fetching all the items to add paths for getStaticPaths
. Then, in getStaticProps
, you get the id
from the route parameters (params
) and bring the single product data for passing the props to the page.
/pages/product/[id].js
import React from "react";
import { getXataClient } from "../../utils/xata";
// ...
const xata = getXataClient();
// props passed in the server while generating the page
function Product({ product }) {
return (
// ...
);
}
export default Product;
// fetching data from xata in server for generating static pages
export async function getStaticProps({ params }) {
// getting filtered data from Xat
const data = await xata.db.products
.filter({
id: params.id,
})
.getFirst();
return {
props: { product: data },
};
}
// pre-rendering all the static paths
export async function getStaticPaths() {
const products = await xata.db.products.getAll();
return {
paths: products.map((item) => ({
params: { id: item.id },
})),
// whether to run fallback incase if user requested a page other than what is passed inside the paths
fallback: true,
};
}
Kill the terminal and again run the yarn dev
command. Open your browser and try to see the details of any product. Now you can see the page ready with the data. Here's mine:
Updating the Product
Let's add the functionality of updating the product. Create an API route for updating data from serverless functions. Create a new file, updateProduct.js
, for updating the data inside your products API directory. Just like creating, you can edit the records in Xata using the update
method of the table. The update
method will identify the record using it’s id
and update the columns with the data
.
/pages/api/products/updateProduct.js
import { getXataClient } from "../../../utils/xata";
const xata = getXataClient();
const handler = async (req, res) => {
// using update method to update records in database
const { id, ...data } = req.body;
try {
await xata.db.products.update(id, { ...data });
res.json({ message: "Success 😁" });
} catch (error) {
console.log(error);
res.status(500).json({ message: error.message });
}
};
export default handler;
Navigate to UpdateProduct.js
component inside /components/Product
to add the function to update the product data. You will send a PUT request to the API endpoint along with the product id and updated data.
/components/Product/UpdateProduct.js
import { baseUrl } from "../../utils/config";
// ...
const UpdateProduct = ({ product, ...props }) => {
// ...
const onFormSubmit = async (data) => {
try {
await fetch(`${baseUrl}api/products/updateProduct`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: product.id, ...data }),
}).then(() => {
handleClose();
window.location.reload();
});
} catch (error) {
console.log(error);
}
};
return (
// ...
);
};
export default UpdateProduct;
Try updating the product data using the form. You will see the data updated on the product details page and Xata table.
Deleting a Product
To delete a specific record from a database, you can use the delete
method with the id
of the product. Create a new file, deleteProduct.js
, inside the /page/api/products
folder.
/pages/api/products/deleteProduct.js
import { getXataClient } from "../../../utils/xata";
const xata = getXataClient();
const handler = async (req, res) => {
// use delete method for deleting the records in database
const { id } = req.body;
try {
await xata.db.products.delete(id);
res.json({ message: "Success 😁" });
} catch (error) {
res.status(500).json({ message: error.message });
}
};
export default handler;
Now, add the handleDelete
function inside the DeleteProduct.js
component.
/components/Product/DeleteProduct.js
import { baseUrl } from "../../utils/config";
// ...
const DeleteProduct = ({ productId }) => {
// ...
const handleDelete = async () => {
try {
await fetch(`${baseUrl}/api/products/deleteProduct`, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id: productId }),
}).then(() => {
handleClose();
window.location.replace("/products");
});
} catch (error) {
console.log(error);
}
};
return (
// ...
);
};
export default DeleteProduct;
You can try deleting the products from the product details page. If it works, you are good to go.
Deploying to Netlify
To make this app available throughout the internet, deploy it to a cloud service. Netlify is an ideal service provider for this project as it supports Xata and Next.js out of the box. Install the Netlify CLI globally using npm
to deploy the app from your terminal. Log in to CLI using your Netlify account.
# installing the Netlify CLI globally
npm i -g netlify-cli
# logging into your Netlify account
ntl login
After successful login, try running ntl init
inside the project folder to configure Netlify. Answer the prompted questions, and you will have the URL for your deployed site. Here’s mine.
If you face any issues, try to fix them following this guide.
Conclusion
In this tutorial, you have successfully created a CRUD app using Next.js, Xata, and Cloudinary. As Jamstack combines several technologies for markup and APIs, you can use other services besides what you used in this tutorial. You can take this tutorial further by creating the frontend of an e-commerce site using your preferred framework.