Keeping track of inventory can be a challenge, even for small businesses, but with the right tools, it doesn’t have to be. Instead of having to deal with spreadsheets for your inventories, why not build a flexible inventory management system that fits your needs?
In this guide, we’ll walk through the process of building an Inventory Management System using Strapi as the backend and React with TanStack for the frontend.
Features of the inventory system
By the end, you’ll have a fully functional inventory system that’s easy to use, scalable, and customizable.
Let’s get started.
We will use Strapi 5 to manage inventory data, categories, and user roles.
First, we'll create a project directory for our app:
mkdir inventory-sys
cd inventory-sys
Then, we'll install Strapi globally and create a new project using one of the following commands:
npx create-strapi@latest backend --quickstart
OR
yarn create strapi-app@latest backend --quickstart
We'll proceed with the installation and login to our Strapi account when prompted (you'll have to signup instead if this is your first time using Strapi).
This process will create a new Strapi 5 project in the inventory-sys
folder.
After that, navigate to the project folder using cd backend
.
If you're not automatically directed to the Strapi panel on your browser, use this command to start up the Strapi server:
npm run develop
OR
yarn develop
We'll input our details, including name, email, and password to assess your admin panel when prompted to login.
The Strapi admin dashboard of our newly created project will open up at the url provided in the terminal: http://localhost:1337/admin
.
Now we're ready to perform other actions such as creating collection types, managing collection, and configuring settings for roles and permissions.
In the Strapi admin panel, create a collection type named Inventory by navigating to the Content-Type Builder page.
We'll click the Create new collection type and create our collection with the following fields:
Field | Type |
---|---|
productID | Number (big integer) |
productName | Text (short text) |
quantity | Number (integer) |
price | Number (decimal) |
category | Enumeration |
supplier | Text (short text) |
productImage | Media (single media) |
We'll then save it and wait for our changes to take effect.
We'll see the newly created collection and its fields, like this:
In our Inventory collection, let's create some entries in each of the fields.
We'll navigate to the "Content Manager" page and click on the Create new entry button at the top-right corner of the page.
After that, proceed to creating new Inventory entries.
Then, we'll configure API permissions to allow public access for fetching inventory data by:
Now the http://localhost:1337/api/inventories
endpoint is ready. We can now make GET
requests to it.
If we visit the endpoint, we'll see that the entires are now being displayed in the JSON response.
That's all for the Strapi configuration. Let's move on to the frontend.
It's time to build the UI of the inventory management system and we'll do this using React and TanStack.
We'll be building the project from scratch so we won't be using the TanStack quick start. Refer to the TanStack Start official documentation.
We'll create a new project directory for the frontend in the root directory of the main project.
Since we're already in the backend directory, we will navigate back to the root directory using the cd ..
command.
After that, we'll create a new directory and initialize it using:
mkdir frontend
cd frontend
npm init -y
This will create a package.json
file in our frontend
folder.
According to the documentation, it is recommended to use TypeScript with TanStack Start. So we'll create a tsconfig.json
file in the root of the frontend
project directory and input these lines of code:
1{
2 "compilerOptions": {
3 "jsx": "react-jsx",
4 "moduleResolution": "Bundler",
5 "module": "ESNext",
6 "target": "ES2022",
7 "skipLibCheck": true,
8 "strictNullChecks": true,
9 },
10}
The dependencies and libraries we'll be needing include:
frontend
folder:npm i @tanstack/react-start @tanstack/react-router vinxi
This will install the package-lock.json
file and node_modules
folder in our frontend
folder.
2. Install React and the Vite React Plugin using:
npm i react react-dom && npm i -D @vitejs/plugin-react vite-tsconfig-paths
npm i -D typescript @types/react @types/react-dom
qs
library will convert a JavaScript object into a query string that is sent to Strapi to enable us define complex populate and filter logic directly in JavaScript.
While the redaxios
library is a lightweight alternative to Axios for making HTTP requests.
We'll install these two libraries using this command in our terminal: 1npm install qs redaxios
npm install @tanstack/react-table @tanstack/react-form @tanstack/react-router
Now that the libraries and dependencies have been installed, let's update the configuration files.
The following are the configuration files we'll be updating.
package.json
to use Vinxi's CLI and set "type": "module"
, like this:1{
2 // ...
3 "type": "module",
4 "scripts": {
5 "dev": "vinxi dev",
6 "build": "vinxi build",
7 "start": "vinxi start"
8 }
9}
app.config.ts
file and configure it, like this:1// app.config.ts
2import { defineConfig } from '@tanstack/start/config'
3import tsConfigPaths from 'vite-tsconfig-paths'
4
5export default defineConfig({
6 vite: {
7 plugins: [
8 tsConfigPaths({
9 projects: ['./tsconfig.json'],
10 }),
11 ],
12 },
13})
From all the installations and configurations, we can see that there's currently no app or folder for the main frontend code. We'll create this manually.
In the frontend directory, we'll create a folder called app
. This will be the main directory for the frontend application using TanStack Start. It will contain everything needed for routing, client-side rendering, and server-side rendering (SSR).
Now inside the app
folder, create the following:
routes
folderroutes
folder called __root.tsx
client.tsx
filessr.tsx
filerouter.tsx
filerouteTree.gen.ts
fileThis is how the folder structure of our frontend
folder looks like now:
1frontend/
2┣ app/
3┃ ┣ routes/
4┃ ┃ ┗ __root.tsx
5┃ ┣ client.tsx
6┃ ┣ router.tsx
7┃ ┣ routeTree.gen.ts
8┃ ┗ ssr.tsx
9┣ node_modules/
10┣ app.config.ts
11┣ package-lock.json
12┣ package.json
13┗ tsconfig.json
Now let's understand what each of these new files do.
1. routes/
: This contains all the pages (routes) of our app. Each file inside will represent a different route.
2. root.tsx
: The will be the root layout. It is a special route that wraps all other pages and defines the layout that wraps all routes in the app.
Paste these lines of code into this app/routes/__root.tsx
file:
1// app/routes/__root.tsx
2import type { ReactNode } from 'react'
3import {
4 Outlet,
5 createRootRoute,
6 HeadContent,
7 Scripts,
8} from '@tanstack/react-router'
9
10export const Route = createRootRoute({
11 head: () => ({
12 meta: [
13 {
14 charSet: 'utf-8',
15 },
16 {
17 name: 'viewport',
18 content: 'width=device-width, initial-scale=1',
19 },
20 {
21 title: 'TanStack Start Starter',
22 },
23 ],
24 }),
25 component: RootComponent,
26})
27
28function RootComponent() {
29 return (
30 <RootDocument>
31 <Outlet />
32 </RootDocument>
33 )
34}
35
36function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
37 return (
38 <html>
39 <head>
40 <HeadContent />
41 </head>
42 <body>
43 {children}
44 <Scripts />
45 </body>
46 </html>
47 )
48}
It contains:
<Outlet />
, where child routes (pages) will be rendered.<HeadContent />
, which manages metadata (e.g., title, meta tags).<Scripts />
, which loads necessary scripts for routing & interactivity.3. router.tsx
This is the router configuration that configures how TanStack Router behaves.
Let's paste these lines of code into this app/routes/router.tsx
file:
1// app/router.tsx
2import { createRouter as createTanStackRouter } from '@tanstack/react-router'
3import { routeTree } from './routeTree.gen'
4
5export function createRouter() {
6 const router = createTanStackRouter({
7 routeTree,
8 scrollRestoration: true,
9 })
10
11 return router
12}
13
14declare module '@tanstack/react-router' {
15 interface Register {
16 router: ReturnType<typeof createRouter>
17 }
18}
It contains:
routeTree
, which is a generated file that lists all routes (created automatically).scrollRestoration: true
, which ensures that when users navigate, the page scrolls to the correct position.4. ssr.tsx
This is the server entry point that handles server-side rendering (SSR) so that when a user visits the site, the server will generate the page before sending it to the browser.
Read more about SSR on TanStack.
We'll paste these lines of code into our app/ssr.tsx
file:
1// app/ssr.tsx
2import {
3 createStartHandler,
4 defaultStreamHandler,
5 } from '@tanstack/start/server'
6 import { getRouterManifest } from '@tanstack/start/router-manifest'
7
8 import { createRouter } from './router'
9
10 export default createStartHandler({
11 createRouter,
12 getRouterManifest,
13 })(defaultStreamHandler)
It containes createStartHandler()
which runs on the server to process and serve routes.
5. client.tsx
This is the client entry point that will hydrate the app on the browser after SSR.
Let's go ahead and paste the following lines of code into the app/client.tsx
file:
1// app/client.tsx
2/// <reference types="vinxi/types/client" />
3import { hydrateRoot } from 'react-dom/client'
4import { StartClient } from '@tanstack/start'
5import { createRouter } from './router'
6
7const router = createRouter()
8
9hydrateRoot(document, <StartClient router={router} />)
It contains:
hydrateRoot(),
which hydrates the app in the browser.<StartClient />
, which initializes TanStack Router on the client.6. routeTree.gen.ts
The content of the app/routes/routeTree.gen.ts
file will be automatically generated to keep track of all routes when we run npm run dev
or npm run start
.
We don’t edit this manually!
Once we're done creating those necessary files, we can go ahead to create or define the first route (page) of our application, which will be the main page or home page.
We'll start by creating a new file in app/routes
directory called index.tsx
and inputting these lines of code, which will return a simple welcome message.
1// app/routes/index.tsx
2import { createFileRoute } from '@tanstack/react-router'
3
4export const Route = createFileRoute('/')({
5 component: Home,
6})
7
8function Home() {
9 return (
10 <div className="p-2">
11 <h3>Welcome! Manage your inventory efficiently</h3>
12 </div>
13 )
14}
We'll then visit "http://localhost:3000/" to view our page on the browser:
We'll create a utils/
folder that will contain helper functions for interacting with Strapi (our backend CMS). These functions will fetch data and handle our product image media URLs properly.
Inside this folder, we'll create two files: strapi.ts
and inventories.tsx
.
strapi.ts
This file will handle Strapi URLs and media files.
Let's input these lines of code into this file:
1// app/utils/strapi.ts
2export function getStrapiURL() {
3 return import.meta.env.VITE_STRAPI_BASE_URL ?? "http://localhost:1337";
4 }
5
6 export function getStrapiMedia(url: string | null) {
7 if (url == null) return null;
8 if (url.startsWith("data:")) return url;
9 if (url.startsWith("http") || url.startsWith("//")) return url;
10 return `${getStrapiURL()}${url}`;
11 }
The file contains two functions:
getStrapiURL()
which gets the base URL of our Strapi API from environment variables. In this case, the VITE_STRAPI_BASE_URL
isn't set in .env
, so it will default to http://localhost:1337 (Strapi's default local URL).getStrapiMedia(url: string | null)
function which generates full URLs for images and media stored in Strapiinventories.tsx
This file is responsible for fetching inventories from our Strapi's API.
First, let's paste these lines of code:
1// app/utils/inventories.tsx
2import { notFound } from "@tanstack/react-router";
3import { createServerFn } from "@tanstack/start";
4import qs from "qs";
5import axios from "redaxios";
6
7import { getStrapiURL } from "./strapi";
8
9const BASE_API_URL = getStrapiURL();
10
11interface StrapiArrayResponse<T> {
12 data: T[];
13 meta: {
14 pagination: {
15 page: number;
16 pageSize: number;
17 pageCount: number;
18 total: number;
19 };
20 };
21}
22
23interface StrapiResponse<T> {
24 data: T;
25}
26
27interface ProductImage {
28 url: string;
29 alternativeText: string;
30}
31
32export type InventoryType = {
33 id: number;
34 productID: string;
35 productName: string;
36 quantity: number;
37 price: number;
38 category: string;
39 supplier: string;
40 productImage: ProductImage;
41};
42
43// Fetch a single inventory item by ID
44export const fetchInventoryItem = createServerFn({ method: "GET" })
45 .validator((id: string) => id)
46 .handler(async ({ data }) => {
47 console.info(`Fetching inventory item with id ${data}...`);
48
49 const path = "/api/inventories/" + data;
50 const url = new URL(path, BASE_API_URL);
51
52 url.search = qs.stringify({
53 populate: {
54 productImage: {
55 fields: ["url", "alternativeText"],
56 },
57 },
58 });
59
60 const inventoryItem = await axios
61 .get<StrapiResponse<InventoryType>>(url.href)
62 .then((r) => r.data.data)
63 .catch((err) => {
64 console.error(err);
65 if (err.status === 404) {
66 throw notFound();
67 }
68 throw err;
69 });
70
71 return inventoryItem;
72 });
73
74// Fetch all inventory items
75export const fetchInventories = createServerFn({ method: "GET" }).handler(
76 async () => {
77 console.info("Fetching inventory items...");
78
79 const path = "/api/inventories";
80 const url = new URL(path, BASE_API_URL);
81
82 url.search = qs.stringify({
83 populate: {
84 productImage: {
85 fields: ["url", "alternativeText"],
86 },
87 },
88 });
89
90 return axios.get<StrapiArrayResponse<InventoryType>>(url.href).then((r) => {
91 console.dir(r.data, { depth: null });
92 return r.data.data;
93 });
94 }
95);
This inventories.tsx
file is responsible for fetching inventory data from Strapi’s REST API. It provides two main functions:
fetchInventoryItem(id: string)
to fetch a single inventory item by ID.fetchInventories()
to fetch all inventory items.
At the top, we imported qs
and redaxios.
The inventories.tsx
file ensures that data is retrieved with necessary relationships (like images) and handles errors properly.
Now in order to enable users route from one page to another, we'll do this in the app/routes/__root.tsx
file.
1//app/routes/__root.tsx
2export const Route = createRootRoute({
3 head: () => ({
4 meta: [
5 ...
6 {
7 title: 'Inventory Management App',
8 },
9 ],
10 }),
11 component: RootComponent,
12})
Link
to the file by updating the TanStack react-router import at the top of this file to this:1import {Outlet, Link, createRootRoute, HeadContent, Scripts,} from "@tanstack/react-router";
<body>
of the RootDocument
function to this to include the inventories page: 1<body>
2 <div>
3 <Link
4 to="/"
5 activeProps={{
6 }}
7 activeOptions={{ exact: true }}
8 >
9 Home
10 </Link>{" "}
11 <Link
12 to="/inventories"
13 activeProps={{
14 }}
15 >
16 Inventories
17 </Link>{" "}
18 </div>
19 {children}
20 <Scripts />
21</body>
And that's all we need to do to include navigation links across the pages in our app.
Our app will contain two pages: the main page and the inventory list page.
We've created the main page as our home page. Now let's improve it by styling and adding navigation link to the inventory list page.
Let's create a basic landing page for our inventory management application by updating the index.tsx
file in our app/routes
folder.
1//app/routes/index.tsx
2import { createFileRoute } from "@tanstack/react-router";
3import { Link } from "@tanstack/react-router";
4
5export const Route = createFileRoute("/")({
6 component: Home,
7});
8
9function Home() {
10 return (
11 <section>
12 <div>
13 <h2>Why Businesses Trust Our Inventory Management</h2>
14 <p>
15 Our inventory management system ensures real-time tracking, reduces
16 waste, and optimizes stock levels. Businesses rely on us for accuracy,
17 efficiency, and seamless integration with their operations.
18 </p>
19 </div>
20 <div>
21 <div>
22 <ul>
23 <li>
24 <span>✔</span>
25 <p>
26 <strong>Real-time Stock Updates</strong>
27 <br />
28 Always know your inventory levels to prevent shortages and
29 overstocking.
30 </p>
31 </li>
32 <li>
33 <span>✔</span>
34 <p>
35 <strong>Automated Restocking</strong>
36 <br />
37 Set up automatic reorders to ensure stock never runs out.
38 </p>
39 </li>
40 <li>
41 <span>✔</span>
42 <p>
43 <strong>Seamless Integrations</strong>
44 <br />
45 Connect with your POS, e-commerce, and accounting systems
46 effortlessly.
47 </p>
48 </li>
49 <li>
50 <span>✔</span>
51 <p>
52 <strong>Comprehensive Reports</strong>
53 <br />
54 Gain insights into trends, stock movements, and business
55 performance.
56 </p>
57 </li>
58 </ul>
59 </div>
60 </div>
61 <Link
62 to="/inventories"
63 activeProps={{
64 className: "font-bold",
65 }}
66 >
67 <button>Get Started</button>
68 </Link>{" "}
69 </section>
70 );
71}
This is just a simple home page. Feel free to refine it and customize to add more details about your inventory app.
This is the page where all our inventories will be listed out in tabular format to display the info about the inventory.
First, let's display the inventory in a list.
We'll create an inventories.tsx
file in our app/routes
directory and paste these lines of code into it:
1//app/routes/inventories.tsx
2import { Link, Outlet, createFileRoute } from '@tanstack/react-router';
3import { fetchInventories } from '../utils/inventories';
4
5export const Route = createFileRoute('/inventories')({
6 loader: async () => fetchInventories(),
7 component: InventoriesComponent,
8});
9
10function InventoriesComponent() {
11 const inventories = Route.useLoaderData();
12
13 return (
14 <div>
15 <div>
16 <h2>Inventories</h2>
17 <ul>
18 {inventories.map((inventory) => {
19 return (
20 <li key={inventory.id}>
21 <div>{inventory.productName.substring(0, 20)}</div>
22 </li>
23 );
24 })}
25 </ul>
26 </div>
27 <div>
28 <Outlet />
29 </div>
30 </div>
31 );
32}
Breakdown of the code
createFileRoute
and Outlet
from @tanstack/react-router` to define a route for our inventory page.fetchInventories
from the inventories
file in the utils folder which will help us fetch the inventory data from our Strapi server. fetchInventories()
) fetches inventory data when the page loads. While the InventoriesComponent
is the component that renders the inventory list from the inventory component function we created underneath it.Route.useLoaderData()
retrieves the inventory data from the route loader.The <Outlet />
allows nested routes to be rendered inside this component.
We'll now go to the browser and navigate to the inventory page by adding /inventories
to the http://localhost:3000
, so it'll be this: http://localhost:3000/inventories
.
We can now see our inventories displayed in a list with only the product names being shown as we want it for now:
But this isn't how we want our inventories to be displayed, so let's fix the display format with TanStack Table and add a few functionalities for searching and filtering.
Update the inventories.tsx
file located in the app/routes
folder with the following lines of code:
1//app/routes/inventories.tsx
2import { Link, Outlet, createFileRoute } from "@tanstack/react-router";
3import { fetchInventories } from "../utils/inventories";
4import {
5 ColumnDef,
6 flexRender,
7 getCoreRowModel,
8 getFilteredRowModel,
9 useReactTable,
10} from "@tanstack/react-table";
11import { useState } from "react";
12import { getStrapiURL } from "../utils/strapi";
13import SearchInput from "../components/SearchInput";
14
15export const Route = createFileRoute("/inventories")({
16 loader: async () => fetchInventories(),
17 component: InventoriesComponent,
18});
19
20export type Inventory = {
21 id: number;
22 productID: string;
23 productName: string;
24 quantity: number;
25 price: number;
26 category: string;
27 supplier: string;
28 productImage: { url: string; alternativeText: string };
29};
30
31function InventoriesComponent() {
32 const inventories = Route.useLoaderData();
33
34 // State for global filter
35 const [globalFilter, setGlobalFilter] = useState("");
36
37 // Define table columns
38 const columns: ColumnDef<Inventory>[] = [
39 {
40 accessorKey: "productID",
41 header: "Product ID",
42 sortingFn: "alphanumeric",
43 },
44 {
45 accessorKey: "productName",
46 header: "Product Name",
47 sortingFn: "alphanumeric",
48 },
49 { accessorKey: "category", header: "Category" },
50 { accessorKey: "supplier", header: "Supplier" },
51 { accessorKey: "quantity", header: "Quantity", sortingFn: "basic" },
52 { accessorKey: "price", header: "Unit Price ($)", sortingFn: "basic" },
53 {
54 accessorKey: "productImage",
55 header: "Image",
56 cell: ({ row }) => (
57 <img
58 src={`http://localhost:1337${row.original.productImage?.url}`}
59 alt={
60 row.original.productImage?.alternativeText ||
61 row.original.productName
62 }
63 className="inventory-image"
64 />
65 ),
66 },
67 ];
68
69 // Create Table Instance
70 const table = useReactTable({
71 data: inventories,
72 columns,
73 state: {
74 globalFilter,
75 },
76 getCoreRowModel: getCoreRowModel(),
77 getFilteredRowModel: getFilteredRowModel(),
78 });
79
80 // State for filter values
81 const [categoryFilter, setCategoryFilter] = useState("");
82 const [supplierFilter, setSupplierFilter] = useState("");
83
84 // Extract unique categories and suppliers for dropdowns
85 const uniqueCategories = [
86 ...new Set(inventories.map((item) => item.category)),
87 ];
88 const uniqueSuppliers = [
89 ...new Set(inventories.map((item) => item.supplier)),
90 ];
91
92 // Filter data based on category and supplier
93 const filteredData = inventories.filter((item) => {
94 return (
95 (categoryFilter ? item.category === categoryFilter : true) &&
96 (supplierFilter ? item.supplier === supplierFilter : true)
97 );
98 });
99
100 return (
101 <div>
102 <h2>Inventory</h2>
103
104 {/* Global Search Filter Input */}
105 <div>
106 <div>
107 <SearchInput
108 value={globalFilter ?? ""}
109 onChange={(value) => setGlobalFilter(String(value))}
110 placeholder="Search all columns..."
111 />
112 </div>
113
114 {/* Clear Filters Button */}
115 <button
116 onClick={() => {
117 setCategoryFilter("");
118 setSupplierFilter("");
119 }}
120 >
121 Clear Filters
122 </button>
123
124 {/* Table Container */}
125 <div>
126 <table>
127 <thead>
128 {table.getHeaderGroups().map((headerGroup) => (
129 <tr key={headerGroup.id}>
130 {headerGroup.headers.map((header) => (
131 <th key={header.id}>
132 {flexRender(
133 header.column.columnDef.header,
134 header.getContext()
135 )}
136 {/* Category filter beside Category header */}
137 {header.column.id === "category" && (
138 <select
139 value={categoryFilter}
140 onChange={(e) => setCategoryFilter(e.target.value)}>
141 <option value="">All</option>
142 {uniqueCategories.map((cat) => (
143 <option key={cat} value={cat}>
144 {cat}
145 </option>
146 ))}
147 </select>
148 )}
149
150 {/* Supplier filter beside Supplier header */}
151 {header.column.id === "supplier" && (
152 <select
153 value={supplierFilter}
154 onChange={(e) => setSupplierFilter(e.target.value)}>
155 <option value="">All</option>
156 {uniqueSuppliers.map((sup) => (
157 <option key={sup} value={sup}>
158 {sup}
159 </option>
160 ))}
161 </select>
162 )}
163 </th>
164 ))}
165 </tr>
166 ))}
167 </thead>
168
169 <tbody>
170 {filteredData.length > 0 ? (
171 filteredData.map((newItem) => (
172 <tr key={newItem.id} className="border-b">
173 {table
174 .getRowModel()
175 .rows.find((row) => row.original.id === newItem.id)
176 ?.getVisibleCells()
177 .map((cell) => (
178 <td key={cell.id} className="p-3">
179 {flexRender(
180 cell.column.columnDef.cell,
181 cell.getContext()
182 )}
183 </td>
184 ))}
185 </tr>
186 ))
187 ) : (
188 <tr>
189 <td
190 colSpan={columns.length}>
191 Oops! No item matching your search was found.
192 </td>
193 </tr>
194 )}
195 </tbody>
196 </table>
197 </div>
198
199 <Outlet />
200 </div>
201 );
202}
203
204export default InventoriesComponent;
Breakdown of the code
ColumnDef
, flexRender
, getFilteredRowModel
, getCoreRowModel
, useReactTable
, ) used to create and manage tables in React from the TanStack Table. useState
hook for managing component state, the getStrapiURL
function to get the Strapi API URL for fetching media assets, and a SearchInput
file for a search input component.export type Inventory
defines a TypeScript type Inventory to represent each inventory item. InventoriesComponent
fucntion, we first retrieved the inventory data loaded by the route and then set a state for global search filtering.productID
and productName
columns are sortable alphanumerically. Our image column will display a thumbnail of the product image.data
for the inventory list, columns
for the defined columns, globalFilter
state for global filtering, getCoreRowModel()
which returns a basic row model that maps the original data passed to the table, and getFilteredRowModel()
to enable filtering.category
and supplier
filters. We then extracted unique categories and suppliers for filter dropdowns. This then filters inventory data based on selected category and supplier.thead
) loops through table.getHeaderGroups()
to create column headers.tbody
) loops through table.getRowModel()
to display inventory data. It then uses flexRender()
to correctly render the cell content.
You'll notice that in the above code, we imported a file called SearchInput
. That's where the functionality for the search input is located, so we'll go ahead and create one.
We'll create a components
folder and a file inside it called SearchInput.tsx
. Now let's paste these lines of code inside it:
1//app/components/SearchInput.tsx
2import { useEffect, useState } from "react";
3
4const Input = ({
5 value: initValue,
6 onChange,
7 debounce = 500,
8 ...props
9}) => {
10 const [value, setValue] = useState(initValue);
11 useEffect(() => {
12 setValue(initValue);
13 }, [initValue]);
14
15 // * 0.5s after set value in state
16 useEffect(() => {
17 const timeout = setTimeout(() => {
18 onChange(value);
19 }, debounce);
20 return () => clearTimeout(timeout);
21 }, [value]);
22
23 return (
24 <input
25 {...props}
26 value={value}
27 onChange={(e) => setValue(e.target.value)}
28 />
29 );
30};
31
32export default Input;
Breakdown of the code:
First, we imported the useState
hook to manage the state of the input value and the useEffect
hook to handle side effects, such as syncing values and implementing the debounce functionality.
The const Input = ({...})
is designed as a controlled component that accepts the following props:
value: initValue
: This is the initial value passed to the component.
onChange
: A function to handle changes when the input updates.
debounce = 500
: Has a default debounce time of 500ms (0.5s) before invoking the onChange
function.
...props
: Allows passing additional props to the element.
initValue
. Whenever initValue
changes (e.g., when the parent component updates it), this effect will ensure that the internal state (value) is also updated.useEffect
is used when value changes to start a timer (setTimeout
). After debounce milliseconds (default: 500ms
), onChange(value)
is called.value
changes before the delay is over, the clearTimeout(timeout)
cancels the previous timer to prevent unnecessary function calls (debouncing). This helps to avoid calling onChange
on every keystroke to improve performance. ...props
) onto the <input>
element.
The value
state controls the value
and the onChange
event updates value whenever the user types.
When we go to our browser, this is how our table looks like as seen in the video below:
We are now able to search for inventory items by their product name or product id and filter the list by category or supplier.
Refer to the TanStack Table documentation to get the complete guide on every features, utilities, and APIs that can be used in the TanStack Table.
But that's not all! We want the user, in this case, an admin of the store to be able to add new inventory items to the table and to the Strapi backend admin panel.
TanStack Form is a headless, performant, and type-safe form state management for TS/JS, React, Vue, Angular, Solid, and Lit. TanStack form will also handle the form validation.
To get started with the "Add" functionality, we'll first create a file inside the components
folder called ItemForm.tsx
and paste the following lines of code in it:
1// app/components/ItemForm.tsx
2import { useForm } from "@tanstack/react-form";
3import * as React from "react";
4
5function FieldInfo({ field }) {
6 return (
7 <>
8 {field.state.meta.isTouched && field.state.meta.errors.length ? (
9 <em>{field.state.meta.errors.join(", ")}</em>
10 ) : null}
11 {field.state.meta.isValidating ? "Validating..." : null}
12 </>
13 );
14}
15
16export default function ItemForm({ onClose, onItemAdded }) {
17 const form = useForm({
18 defaultValues: {
19 productID: 1,
20 productName: "",
21 quantity: 1,
22 price: 1,
23 category: "",
24 supplier: "",
25 productImage: null as File | null, // Added image field
26 },
27 onSubmit: async ({ value }) => {
28 try {
29 // Step 1: Create the inventory entry
30 const { productImage, ...formData } = value; // Exclude image from first request
31 const payload = { data: formData };
32
33 const entryResponse = await fetch("http://localhost:1337/api/inventories", {
34 method: "POST",
35 headers: { "Content-Type": "application/json" },
36 body: JSON.stringify(payload),
37 });
38
39 if (!entryResponse.ok) throw new Error("Failed to create entry");
40
41 const entryData = await entryResponse.json();
42 console.log("Entry created:", entryData);
43 const documentId = entryData.data.documentId;
44
45 // Step 2: Upload the image
46 const formDataImage = new FormData();
47 if (productImage) {
48 formDataImage.append("files", productImage); // Append selected file
49 }
50
51 const imageResponse = await fetch("http://localhost:1337/api/upload", {
52 method: "POST",
53 body: formDataImage,
54 });
55
56 if (!imageResponse.ok) throw new Error("Failed to upload image");
57
58 const uploadedImage = await imageResponse.json();
59 console.log("Image uploaded:", uploadedImage);
60 const imageId = uploadedImage[0].id;
61
62 // Step 3: Perform PUT request to update entry with image ID
63 const updatePayload = {
64 data: { productImage: imageId },
65 };
66
67 const updateResponse = await fetch(`http://localhost:1337/api/inventories/${documentId}`, {
68 method: "PUT",
69 headers: { "Content-Type": "application/json" },
70 body: JSON.stringify(updatePayload),
71 });
72
73 if (!updateResponse.ok) throw new Error("Failed to update entry with image");
74 console.log("Entry updated with image", updateResponse);
75
76 onItemAdded(entryData.data.attributes || entryData.data);
77 alert("Item added successfully with image!");
78 window.location.reload(); // Reload page to show new item
79 } catch (error) {
80 console.error("Error during item creation:", error);
81 alert("Error adding item.");
82 }
83 },
84 });
85
86 return (
87 <div>
88 <form
89 onSubmit={(e) => {
90 e.preventDefault();
91 e.stopPropagation();
92 form.handleSubmit();
93 }}
94 >
95 {/* Product ID */}
96 <form.Field
97 name="productID"
98 validators={{
99 onChange: ({ value }) =>
100 !value || value <= 0 ? "Product ID must be greater than 0" : undefined,
101 }}
102 children={(field) => (
103 <>
104 <label htmlFor={field.name}>Product ID</label>
105 <input
106 id={field.name}
107 type="number"
108 value={field.state.value}
109 onChange={(e) => field.handleChange(Number(e.target.value))}
110 />
111 <FieldInfo field={field} />
112 </>
113 )}
114 />
115
116 {/* Product Name */}
117 <form.Field
118 name="productName"
119 validators={{
120 onChange: ({ value }) =>
121 !value ? "Product name is required" : value.length < 3 ? "Must be at least 3 characters" : undefined,
122 }}
123 children={(field) => (
124 <>
125 <label htmlFor={field.name}>Product Name</label>
126 <input id={field.name} value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} />
127 <FieldInfo field={field} />
128 </>
129 )}
130 />
131
132 {/* Quantity */}
133 <form.Field
134 name="quantity"
135 validators={{
136 onChange: ({ value }) => (value <= 0 ? "Quantity must be at least 1" : undefined),
137 }}
138 children={(field) => (
139 <>
140 <label htmlFor={field.name}>Quantity</label>
141 <input
142 id={field.name}
143 type="number"
144 value={field.state.value}
145 onChange={(e) => field.handleChange(Number(e.target.value))}
146 />
147 <FieldInfo field={field} />
148 </>
149 )}
150 />
151
152 {/* Price */}
153 <form.Field
154 name="price"
155 validators={{
156 onChange: ({ value }) => (value <= 0 ? "Price must be greater than 0" : undefined),
157 }}
158 children={(field) => (
159 <>
160 <label htmlFor={field.name}>Price</label>
161 <input
162 id={field.name}
163 type="number"
164 value={field.state.value}
165 onChange={(e) => field.handleChange(Number(e.target.value))}
166 />
167 <FieldInfo field={field} />
168 </>
169 )}
170 />
171
172 {/* Category */}
173 <form.Field
174 name="category"
175 validators={{
176 onChange: ({ value }) => (!value ? "Category is required" : undefined),
177 }}
178 children={(field) => (
179 <>
180 <label htmlFor={field.name}>Category</label>
181 <input id={field.name} value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} />
182 <FieldInfo field={field} />
183 </>
184 )}
185 />
186
187 {/* Supplier */}
188 <form.Field
189 name="supplier"
190 validators={{
191 onChange: ({ value }) => (!value ? "Supplier is required" : undefined),
192 }}
193 children={(field) => (
194 <>
195 <label htmlFor={field.name}>Supplier</label>
196 <input id={field.name} value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} />
197 <FieldInfo field={field} />
198 </>
199 )}
200 />
201
202 {/* Image Upload */}
203 <form.Field
204 name="productImage"
205 validators={{
206 onChange: ({ value }) => (!value ? "Image is required" : undefined),
207 }}
208 children={(field) => (
209 <>
210 <label htmlFor={field.name}>Product Image</label>
211 <input
212 id={field.name}
213 type="file"
214 accept="image/*"
215 onChange={(e) => {
216 const file = e.target.files?.[0] || null;
217 field.handleChange(() => file);
218 }}
219 />
220 <FieldInfo field={field} />
221 </>
222 )}
223 />
224
225 {/* Buttons */}
226 <form.Subscribe
227 selector={(state) => [state.canSubmit, state.isSubmitting]}
228 children={([canSubmit, isSubmitting]) => (
229 <>
230 <button type="submit" disabled={!canSubmit}>
231 {isSubmitting ? "..." : "Submit"}
232 </button>
233 <button type="reset" onClick={() => form.reset()}>
234 Reset
235 </button>
236 <button type="button" onClick={onClose}>
237 Cancel
238 </button>
239 </>
240 )}
241 />
242 </form>
243 </div>
244 );
245}
Breakdown of the code:
useForm
from the @tanstack/react-form library to manage form state and validation.FieldInfo
is a helper component that displays validation errors and validation status for a form field. field.state.meta.isTouched
checks if the user has interacted with the field.field.state.meta.errors.length
checks if there are validation errors.field.state.meta.isValidating
shows "Validating..." when validation is in progress.ItemForm
handles inventory item submissions. It take two props: onClose
, which closes the form and onItemAdded
which is a callback for when an item is successfully added.useForm
to initialize the form with default values for product fields, productImage
field set as null initially, and onSubmit
to handle form submission.productImage
separately to exclude it from the first request and prepare a payload with other form data.entryResponse
creates a new inventory entry in Strapi by sending a POST request. If the request fails, an error is thrown. We then parse the response and extract the new inventory documentId
.FormData
object and append the selected image. This will send the image to Strapi's /upload
endpoint.PUT
request to update entry with image ID. onItemAdded
, alerts success, and reloads the page to show the newly added item. And of course, any error is caught and logged. form.handleSubmit()
to process the form.name="productID"
links the field to the form state.validators
ensures the field value is greater than 0.children
renders a <label>
, an <input>
field bound to the form state, and the FieldInfo
to show errors.productImage
field uses the <input type="file">
to select an image and calls field.handleChange()
to update the form state.form.Subscribe
contains buttons for submitting the form, reseting form values, and cancelling submission.NB: We are sending the image to Strapi's
/upload
endpoint and to make sure this works, we have to configure API permissions to allow public access for uploading images in our Strapi admin panel, just like we did at the beginning for the Inventory.
Navigate to Settings → Users & Permissions Plugin → Roles → Public. Click the 'edit' icon in Public.
We'll then toggle the Upload option under "Permission" and tick the Select all checkbox for the collection and then click the "Save" button to save the settings.
The last thing we have to do is update the inventory page to include the add item functionality.
In the inventories.tsx
file, we'll do the following:
ItemForm.tsx
file from the components
folder handleItemAdded
function to update the inventoryList
by adding the newly created item. The spread operator (...prev
) will keep existing inventory items intact. We'll also include a default productDetails
property.showForm
to true, making the form appear.To render the form when needed, we'll:
Make the ItemForm
component appear only when showForm
is true.
Set onClose
prop to allow closing the form.
Include the onItemAdded
prop to pass handleItemAdded
to ItemForm
, enabling it to update the inventory list when a new item is successfully added.
What our form looks like after styling:
As an admin of this inventory system, you'd want to be able to delete an inventory item directly on the app instead of doing it from the Strapi admin panel. Here's how we can achieve that:
documentId?
to the export type Inventory
to act as the unique identifier for the inventory item to be deleted. Set the value to string, like this:1documentId?: string;
handleDelete
function in the routes/inventories.tsx
file1// app/routes/inventories.tsx
2const handleDelete = async (documentId: string) => {
3 try {
4 const response = await fetch(`http://localhost:1337/api/inventories/${documentId}`, {
5 method: "DELETE",
6 headers: { "Content-Type": "application/json" },
7 });
8
9 if (response.ok) {
10 setInventoryList((prev) => prev.filter((item) => item.documentId !== documentId));
11 alert("Item deleted successfully!");
12 console.log("Item deleted successfully!");
13 } else {
14 throw new Error("Failed to delete item");
15 }
16 } catch (error) {
17 console.error(error);
18 alert("Error deleting item");
19 }
20 };
documentId
as a parameter, which is the unique identifier for the inventory item to be deleted. const = response...
makes a DELETE
request to http://localhost:1337/api/inventories/{documentId} to remove the specific inventory item from the Strapi backend. (response.ok)
, the function updates the UI.inventoryList
is updated by filtering out the deleted item to ensure the UI reflects the deletion without needing a page refresh. A success message is displayed and logged to the console.1<button
2 onClick={() => handleDelete(newItem?.documentId)}
3 className="delete-btn"
4>
5 🗑️
6</button>
handleDelete(documentId)
, which then deletes the corresponding item.Refer to the Inventory page code in the GitHub to see the full code that handles these functionalities.
Don't forget to style your pages. Refer to the stylesheet in the GitHub repo to get the exact styling used in this app.
And that's it!
We've succesfully built an inventory management system using Strapi for the backend inventory management and TanStack and its libraries for the frontend.
Let's test out our app.
Go to your localhost on your web browser. You may need to refresh the page to ensure it has been updated.
Now, add an inventory item and watch as it's added to the inventory list.
Go to your Strapi admin panel to see it has also been added as an entry in the content manager. Also try deleting an inventory item to see the changes reflected.
Want to explore the full code? Check out the full project here in this GitHub Repo.
Building an inventory management app with TanStack makes things a whole lot easier. From handling forms with TanStack Form, managing dynamic tables with TanStack Table, to easy navigation using TanStack Router, everything works well together.
If you made it this far, thanks for following along. Hopefully, this guide gave you a clearer picture of how to use TanStack’s powerful tools together with Strapi to build a functional inventory management system. You can always upgrade this system to include more functionalties.
Juliet is a developer and technical writer whose work aims to break down complex technical concepts and empower developers of all levels.