Skip to content

How to Build a Full Stack Content Management System Using Next.js, Xata, and Cloudinary

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’ll 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.

final demo of the application - ecommerce cms

Here’s the source code and the final demo if you want to dive directly into the code.

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.

You’ll build a CMS for managing the content of an e-ommerce 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’ll expose the content for the front-end via API endpoints and use the data however you like.

  • 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 CloudinaryXata, and Netlify accounts to follow along. These services provide a generous free tier you can use for this project.

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
Code language: PHP (php)

The starter code includes all essential dependencies, such as Tailwind CSS, Xata, and others. It also contains several ready-made front-end 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’ll be able to see the app running at http://localhost:3000. You can also see the preview inside your codesandbox.

preview of the starter project

I already created the UI of the application in the starter code to make the process simpler. The main things inside the project are:

  1. 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.
  2. The **/components** directory. If you’re 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
  3. The **/pages/products.js** file. The route for displaying the products. You can find the components used here inside the /components/Products folder.
  4. 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 the id as a parameter inside that route.

Let’s configure the database and add API endpoints to make the UI interactive.

Xata is the serverless database that you’ll 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
Code language: CSS (css)

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:

Xata CLI asking to create API key

For this project, select Create a new API key in the browser; give the API key a name when the browser window pops up.

Creating a new Xata API key inside browser

Workspaces symbolize your team, and they’re 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.

Preview of workspace and database in Xata

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"
        }
      ]
    }
  ]
}

Code language: JavaScript (javascript)

Now, execute the following command to populate the database with the given schema.

xata init --schema=schema.json --codegen=utils/xata.js
Xata database creation process in CLI

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’ll 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.

Xata database with randomly generated 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’ll 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;

Code language: JavaScript (javascript)

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.

Form for creating a new product inside the database

Before adding a new product to the database, you’ll 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:

  1. Signed presets.
  2. 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.
Adding a new upload preset inside Cloudinary

Configure the NameSigning 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.

Configuring the new upload preset inside cloudinary

Once saved, go to the previous page, where you can find the new unsigned preset.

The new preset added

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;

Code language: JavaScript (javascript)

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 of react-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;

Code language: JavaScript (javascript)

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 (
    // ...
  );
};

Code language: JavaScript (javascript)

Now, try to add new products using the form. You can see the files uploaded to your Cloudinary and records created in Xata.

Xata database records created using the UI

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’s 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;

Code language: JavaScript (javascript)

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 (
    // ...
  );
}

// ...
Code language: JavaScript (javascript)

In the above code, you’re 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.

Displaying all the product data in the UI, fetching from database

You’ll 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.

You can find the /product folder inside the /pages directory. As mentioned, I added the [id].js file to generate dynamic routes. You’ll 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’re 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,
  };
}

Code language: JavaScript (javascript)

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:

Statically generated product details page

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;

Code language: JavaScript (javascript)

Navigate to UpdateProduct.js component inside /components/Product to add the function to update the product data. You’ll 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;

Code language: JavaScript (javascript)

Try updating the product data using the form. You’ll see the data updated on the product details page and Xata table.

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;

Code language: JavaScript (javascript)

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;

Code language: JavaScript (javascript)

You can try deleting the products from the product details page. If it works, you’re good to go.

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
Code language: PHP (php)

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.

Netlify configuration using netlify-cli

If you face any issues, try to fix them following this guide.

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.

Back to top

Featured Post