Build an Inventory Tracker

Milecia

This will use Next.js to let users upload images of their items and do immediate updates as an admin.

Initial setup

There are a couple of things you need to have in place before we start working on the Next app. First, we'll be using Cloudinary to host the product images. So if you don't have a free account, you can go make one here. We'll also be using a PostgreSQL database to hold all of the product data, so if you don't have a local instance, you can download it for free here.

Create a new Next app

Now we can dive straight into the app and start by generating a new Next project. Run the following command in your terminal:

1$ yarn create next-app --typescript

This will create a new Next project and it will prompt you for a name. I've called this project inventory-tracker, but feel free to call it anything you like. We're going to use Next's built-in API routes to handle our database operations since it's basic CRUD operations. Since we're talking about the database, let's go ahead and set up Prisma to handle those operations with our local Postgres instance.

Prisma set up

Let's start by adding the prisma package to the project with the following command:

1$ yarn add prisma @prisma/client

Now we can use the Prisma CLI to make the config files we need to connect our project to Postgres. Run the following command to set up Prisma:

1$ npx prisma init

This generates a .env file at the root of your project and a new prisma directory that has the schema.prisma file that connects the whole app to Postgres. This is also where we will define the database schema. But first, open the .env file and update the connection string with your local Postgres username, password, and database name.

Defining the database schema

Now open the schema.prisma file because we can add the models for the table we'll use here. Below the datasource where we define the connection to the database, add the following code:

1// schema.prisma
2...
3model Product {
4 id String @id @default(uuid())
5 name String
6 sku String @default(uuid())
7 image String
8 quantity Int
9 storeId String
10}

This defines the columns of the Product table that will help us keep all the items in multiple stores available. Now that we have the database defined, we can run a migration to make these changes in the database.

Migrating changes to the database

We'll run another command to handle the migration using Prisma. In your terminal, run this:

1$ npx prisma migrate dev --name init

This will create the Product table in your database with all the fields we have defined. Now we can move on to the API route for this app. We'll write all of the CRUD logic for it in a new file.

Adding a Next API route

Go to the pages > api directory and add a new file called inventory.ts. This is where we'll make the calls to our database using Prisma. Open this file and add the follow code:

1// inventory.ts
2import type { NextApiRequest, NextApiResponse } from "next";
3import { PrismaClient } from "@prisma/client";
4
5interface Product {
6 id: string;
7 name: string;
8 sku: string;
9 image: string;
10 quantity: number;
11 storeId: string;
12}
13
14const prisma = new PrismaClient();
15
16export default async function handler(
17 req: NextApiRequest,
18 res: NextApiResponse<Product[] | Product | null>
19) {
20 switch (req.query.type) {
21 case "products":
22 getAllProducts(req, res);
23 break;
24 case "product":
25 getProduct(req, res);
26 break;
27 case "create":
28 createProduct(req, res);
29 break;
30 case "update":
31 updateProduct(req, res);
32 break;
33 case "delete":
34 deleteProduct(req, res);
35 break;
36 default:
37 res.status(200).json([]);
38 }
39}
40
41async function getAllProducts(
42 req: NextApiRequest,
43 res: NextApiResponse<Product[]>
44) {
45 const allProducts = await prisma.product.findMany();
46
47 res.status(200).json(allProducts || []);
48}
49
50async function getProduct(
51 req: NextApiRequest,
52 res: NextApiResponse<Product | null>
53) {
54 const product = await prisma.product.findUnique({
55 where: {
56 id: req.body.id,
57 },
58 });
59
60 res.status(200).json(product);
61}
62
63async function createProduct(
64 req: NextApiRequest,
65 res: NextApiResponse<Product>
66) {
67 const newProduct = await prisma.product.create({
68 data: {
69 name: req.body.product.name,
70 sku: req.body.product.sku,
71 image: req.body.product.image,
72 quantity: req.body.product.quantity,
73 storeId: req.body.product.storeId,
74 },
75 });
76
77 res.status(200).json(newProduct);
78}
79
80async function updateProduct(
81 req: NextApiRequest,
82 res: NextApiResponse<Product>
83) {
84 const updatedProduct = await prisma.product.update({
85 where: { id: req.body.modifiedProduct.id },
86 data: {
87 name: req.body.modifiedProduct.name,
88 sku: req.body.modifiedProduct.sku,
89 image: req.body.modifiedProduct.image,
90 quantity: req.body.modifiedProduct.quantity,
91 storeId: req.body.modifiedProduct.storeId,
92 },
93 });
94
95 res.status(200).json(updatedProduct);
96}
97
98async function deleteProduct(
99 req: NextApiRequest,
100 res: NextApiResponse<Product>
101) {
102 const deleteProduct = await prisma.product.delete({
103 where: { id: req.body.productId },
104 });
105
106 res.status(200).json(deleteProduct);
107}

There's quite a bit going on here so we'll walk through it. We import some package and then define the Product type to appease TypeScript requirements. Then we instantiate a new Prisma client to connect to our database. After that is the handler function. This will take the request and call a different function based on the referer value. Finally, we have the getProducts, getProduct, createProduct, updateProduct, and deleteProduct methods.

These make all of the database queries that get sent to our Postgres instance. Each of these methods returns a product-typed response to the requester. So if you want to do some testing in Postman before you work with the front-end. Once you feel like your endpoints are good to go, we can switch over to the front-end.

Setting up Tailwind CSS

We're going to use Tailwind CSS to handle our styles so let's install that package with the following command:

1$ yarn add tailwindcss postcss autoprefixer @heroicons/react axios

Then run the following command to initialize Tailwind in the project:

1$ npx tailwindcss init -p

This will add a new file to the root of your directory called tailwind.config.js and it handles all of the settings you choose for Tailwind. Open this file and update the content line to match this:

1// tailwind.config.js
2...
3content: [
4 "./pages/**/*.{js,ts,jsx,tsx}",
5 "./components/**/*.{js,ts,jsx,tsx}",
6 ],
7...

This makes it so Tailwind knows which files to style. Now go to styles > globals.css and delete all of the styles. We're going to add these Tailwind directives to this stylesheet so we can use the classes throughout the front-end:

1@tailwind base;
2@tailwind components;
3@tailwind utilities;

Let's update one more config file so that we're able to get our images from Cloudinary. This will let Next access the source for any images. Open the next.config.ts file at the root of the project and make the following update:

1// next.config.ts
2const nextConfig = {
3 reactStrictMode: true,
4 images: {
5 domains: ['res.cloudinary.com']
6 },
7...

Now that Tailwind is ready to go, let's create the main view for the products.

Displaying, editing, adding, and deleting products

In the pages directory, open the index.tsx file and delete all of the existing code. This page will be the product table that lists everything in the inventory and there will be buttons that allow us to create new products or interact with existing ones. Let's start by adding a few imports and a type dedinition at the top of the file:

1// index.tsx
2import { useState } from "react";
3import axios from "axios";
4import Image from "next/image";
5import {
6 PlusCircleIcon,
7 PencilAltIcon,
8 TrashIcon,
9} from "@heroicons/react/solid";
10import Modal from "../components/Modal";
11
12interface Product {
13 id: string;
14 name: string;
15 sku: string;
16 image: string;
17 quantity: number;
18 storeId: string;
19}

We'll make the Modal component a little later, so for now we'll add the request to get the product information from the back-end using our API route. We'll use the getServerSideProps method because we want this page to update every time a user visits it so they can get the most recent information. Add the following code to the bottom of the file:

1// index.tsx
2...
3export async function getServerSideProps() {
4 const productsRes = await fetch('http://localhost:3000/api/inventory?type=products')
5
6 const products = await productsRes.json()
7
8 return {
9 props: {
10 products
11 },
12 }
13}

Now we can focus on what gets rendered on the page. In between the Product type definition and the server-side request for all the products, add the following code:

1// index.tsx
2...
3export default function Products({ products }) {
4 const [newModal, setNewModal] = useState<boolean>(false);
5 const [editModal, setEditModal] = useState<boolean>(false);
6 const [index, setIndex] = useState<number>(0);
7
8 const deleteProduct = async (productId: string) => {
9 await axios.delete("/api/inventory?type=delete", { data: { productId } });
10 };
11
12 return (
13 <div>
14 <div className="flex gap-2" onClick={() => setNewModal(true)}>
15 <div className="flex gap-2 bg-gray-700 hover:bg-gray-300 text-white font-bold py-2 px-4 rounded-full m-6">
16 <PlusCircleIcon className="h-6 w-6 text-green-500" />
17 Add a product
18 </div>
19 </div>
20 <div className="flex flex-col gap-2">
21 {products.length > 0 ? (
22 products.map((product: Product, index: number) => (
23 <div key={product.id} className="flex gap-6 border-b-2 w-full">
24 <Image
25 src={product.image}
26 alt={product.name}
27 width="100"
28 height="100"
29 />
30 <div>{product.name}</div>
31 <div>{product.quantity}</div>
32 <div>{product.storeId}</div>
33 <PencilAltIcon
34 className="h-6 w-6 text-indigo-500"
35 onClick={() => {
36 setIndex(index);
37 setEditModal(true);
38 }}
39 />
40 <TrashIcon
41 className="h-6 w-6 text-red-400"
42 onClick={() => deleteProduct(product.id)}
43 />
44 </div>
45 ))
46 ) : (
47 <div>Add some new products</div>
48 )}
49 </div>
50 {newModal && <Modal product={{}} onCancel={() => setNewModal(false)} />}
51 {editModal && (
52 <Modal product={products[index]} onCancel={() => setEditModal(false)} />
53 )}
54 </div>
55 );
56}
57...

There's a lot happening in this component. First, we define a few states we'll need to toggle whether a modal needs to be displayed or not and the index of the product that will get sent to the modal if necessary.

Then we have the function that will delete a product record if we need to. It makes a request to the back-end and sends the product id so the correct record is removed.

Finally, we're rendering a button to add new products and then displaying the default message or the product data as rows on the page and each row has product information and a couple of buttons that either display an edit modal or trigger a delete action for that product.

If you run the app with yarn dev now, you should see something like this.

We have all of the CRUD functionality in place so all that's left is adding that Modal component we referenced earlier.

Product modal component

Now we'll need to add a new folder to the root of the project called components and then add a new file called Modal.tsx. This component will have a lot of Tailwind styling on it, a few props passed from the index.tsx file and a couple of functions. Add the following code to this file and we can go over what's happening.

1// Modal.tsx
2import axios from "axios";
3
4export default function Modal({ product, onCancel }) {
5 const addProduct = async (e) => {
6 e.preventDefault();
7
8 const product = {
9 name: e.target.name.value,
10 sku: e.target.sku.value,
11 image: e.target.image.value,
12 quantity: Number(e.target.quantity.value),
13 storeId: e.target.storeId.value,
14 };
15
16 await axios.post("/api/inventory?type=create", { product });
17
18 onCancel();
19 };
20
21 const updateProduct = async (e) => {
22 e.preventDefault();
23
24 const modifiedProduct = {
25 id: product.id,
26 name: e.target.name.value,
27 sku: e.target.sku.value,
28 image: e.target.image.value,
29 quantity: Number(e.target.quantity.value),
30 storeId: e.target.storeId.value,
31 };
32
33 await axios.patch("/api/inventory?type=update", { modifiedProduct });
34
35 onCancel();
36 };
37
38 return (
39 <div
40 className="fixed z-10 inset-0 overflow-y-auto"
41 aria-labelledby="modal-title"
42 role="dialog"
43 aria-modal="true"
44 >
45 <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
46 <div
47 className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
48 aria-hidden="true"
49 ></div>
50 <span
51 className="hidden sm:inline-block sm:align-middle sm:h-screen"
52 aria-hidden="true"
53 >
54 &#8203;
55 </span>
56 <div className="relative inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
57 <div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
58 <div className="sm:flex sm:items-start">
59 <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
60 <h3
61 className="text-lg leading-6 font-medium text-gray-900"
62 id="modal-title"
63 >
64 Add a new product
65 </h3>
66 <div className="mt-2">
67 <form
68 className="w-full max-w-sm"
69 onSubmit={product ? updateProduct : addProduct}
70 >
71 <div className="md:flex md:items-center mb-6">
72 <div className="md:w-1/3">
73 <label
74 className="block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4"
75 htmlFor="name"
76 >
77 Product Name
78 </label>
79 </div>
80 <div className="md:w-2/3">
81 <input
82 className="bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500"
83 id="product-name"
84 name="name"
85 type="text"
86 defaultValue={product ? product.name : ""}
87 />
88 </div>
89 </div>
90 <div className="md:flex md:items-center mb-6">
91 <div className="md:w-1/3">
92 <label
93 className="block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4"
94 htmlFor="sku"
95 >
96 SKU
97 </label>
98 </div>
99 <div className="md:w-2/3">
100 <input
101 className="bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500"
102 id="sku"
103 name="sku"
104 type="text"
105 defaultValue={product ? product.sku : ""}
106 />
107 </div>
108 </div>
109 <div className="md:flex md:items-center mb-6">
110 <div className="md:w-1/3">
111 <label
112 className="block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4"
113 htmlFor="quantity"
114 >
115 Quantity
116 </label>
117 </div>
118 <div className="md:w-2/3">
119 <input
120 className="bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500"
121 id="quantity"
122 name="quantity"
123 type="number"
124 defaultValue={product ? product.quantity : 0}
125 />
126 </div>
127 </div>
128 <div className="md:flex md:items-center mb-6">
129 <div className="md:w-1/3">
130 <label
131 className="block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4"
132 htmlFor="image"
133 >
134 Image
135 </label>
136 </div>
137 <div className="md:w-2/3">
138 <input
139 className="bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500"
140 id="image"
141 name="image"
142 type="text"
143 defaultValue={product ? product.image : ""}
144 />
145 </div>
146 </div>
147 <div className="md:flex md:items-center mb-6">
148 <div className="md:w-1/3">
149 <label
150 className="block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4"
151 htmlFor="storeId"
152 >
153 Store Id
154 </label>
155 </div>
156 <div className="md:w-2/3">
157 <input
158 className="bg-gray-200 appearance-none border-2 border-gray-200 rounded w-full py-2 px-4 text-gray-700 leading-tight focus:outline-none focus:bg-white focus:border-purple-500"
159 id="storeId"
160 name="storeId"
161 type="text"
162 defaultValue={product ? product.storeId : ""}
163 />
164 </div>
165 </div>
166 <div className="md:flex md:items-center">
167 <div className="md:w-1/3"></div>
168 <div className="md:w-1/3">
169 <button
170 className="shadow bg-purple-500 hover:bg-purple-400 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded"
171 type="button"
172 onClick={onCancel}
173 >
174 Cancel
175 </button>
176 </div>
177 <div className="md:w-2/3">
178 <button
179 className="shadow bg-purple-500 hover:bg-purple-400 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded"
180 type="submit"
181 >
182 Save Product
183 </button>
184 </div>
185 </div>
186 </form>
187 </div>
188 </div>
189 </div>
190 </div>
191 </div>
192 </div>
193 </div>
194 );
195}

We have just one import in this component so that we can use the axios package. Then the Modal component takes in an optional product object and a required onCancel method. After this, we define the calls to the back-end to create or edit a product. In the rendered elements, we do a quick check to see if there has been a product passed to the modal.

If there is, we create our rows of products and if there is a product passed, we show the default message as we saw earlier. Either way, we render a form with the product fields we can set values for. If the product info is available, it will be set as the default value for the fields. The submit function also updates based on whether we are editing existing information or not.

Now if you run the app and click the add product button, your page should look like this.

Add a new product

All of the functionality is connected now, so all we have to do is start adding new products to the inventory. With your modal open, go ahead and add some info for a product and save it.

When you refresh the page, you'll see something similar to this.

That's everything! Now you have a fully functional inventory app.

Finished code

You can check out all of the code in the inventory-tracker folder of this repo. You can also check out the app in this Code Sandbox.

Conclusion

Once you have the CRUD functionality set up for an app, you can expand it in a number of directions. You could make more advanced data displays so that users see the important statuses earlier. Or you can take the front-end and make it more user-friendly.

Milecia

Software Team Lead

Milecia is a senior software engineer, international tech speaker, and mad scientist that works with hardware and software. She will try to make anything with JavaScript first.