Introduction
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
- Store and manage inventory records.
- List inventory items.
- Add inventory items with form validation and and delete inventory items.
- View detailed information for each inventory item.
By the end, you’ll have a fully functional inventory system that’s easy to use, scalable, and customizable.
Let’s get started.
Overview of Tech Stack
Backend
We will use Strapi 5 to manage inventory data, categories, and user roles.
Frontend
- React: Frontend framework for UI development.
- TanStack Table: Displays inventory in a sortable, paginated table.
- TanStack Form: Manages inventory registration and updates.
- TanStack Router: Manages routing between main page, inventory list and any other pages we have.
Setting Up Strapi as Backend for the Inventory Management
Install Strapi 5
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.
Create the Inventory Collection Type
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:
Create Entries for Collections
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.
Enable Strapi API Permission
Then, we'll configure API permissions to allow public access for fetching inventory data by:
- Navigating to Settings → Users & Permissions Plugin → Roles → Public.
- Click the 'edit' icon in Public.
- We'll then toggle the Inventory collection under "Permission" and tick the Select all checkbox for the collection. Save it.
Remember To add Image for API Permission
Test API
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.
Setting Up the React Frontend with TanStack
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.
Create a New Directory and Initialize It
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.
Configure TypeScript
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}
Install Necessary Dependencies and Libraries for The Frontend
The dependencies and libraries we'll be needing include:
- Vinxi: What currently powers TanStack Start
- React
- Vite React Plugin: The default Vite plugin for React projects.
- TypeScript
- TanStack libraries (Router, Table, Form)
- qs and redaxios
- Install the first two TanStack libraries using this command in the
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
- Next, install TypeScript and types using:
npm i -D typescript @types/react @types/react-dom
- The
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 theredaxios
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
- And finally, let's install the TanStack libraries we'll need using:
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.
Update Frontend Configuration Files
The following are the configuration files we'll be updating.
- First, we'll update the
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}
- Now, we'll create a TanStack Start's
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})
TanStack Folder Structure
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:
- A
routes
folder - A file inside the
routes
folder called__root.tsx
- A
client.tsx
file - A
ssr.tsx
file - A
router.tsx
file - A
routeTree.gen.ts
file
This 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!
Setup Main TanStack Start Route of The Frontend Application
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:
Helper Functions for Interacting with Strapi
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
.
1. 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:
- The
getStrapiURL()
which gets the base URL of our Strapi API from environment variables. In this case, theVITE_STRAPI_BASE_URL
isn't set in.env
, so it will default to http://localhost:1337 (Strapi's default local URL). - And the
getStrapiMedia(url: string | null)
function which generates full URLs for images and media stored in Strapi
2. inventories.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.
Adding Routes to Other Pages in TanStack Start
Now in order to enable users route from one page to another, we'll do this in the app/routes/__root.tsx
file.
- First, let's change the title of our app from the "TanStack Start Starter" to "Inventory Management System" in the meta of the file, like this:
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})
- Import
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";
- Now we'll update the
<body>
of theRootDocument
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.
Fetching Inventory
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.
1. Home 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.
2. The Inventory List Page
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
- We imported the
createFileRoute
andOutlet
from @tanstack/react-router` to define a route for our inventory page. - Then we imported the
fetchInventories
from theinventories
file in the utils folder which will help us fetch the inventory data from our Strapi server. - Next, we define a route (/inventories) in the application. The loader function (
fetchInventories()
) fetches inventory data when the page loads. While theInventoriesComponent
is the component that renders the inventory list from the inventory component function we created underneath it. - Then the
Route.useLoaderData()
retrieves the inventory data from the route loader. - Finally, we rendered the inventory in a list.
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.
Creating a TanStack Table with Search 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
- First, we imported the necessary hooks and utilities (
ColumnDef
,flexRender
,getFilteredRowModel
,getCoreRowModel
,useReactTable
, ) used to create and manage tables in React from the TanStack Table. - We also imported the
useState
hook for managing component state, thegetStrapiURL
function to get the Strapi API URL for fetching media assets, and aSearchInput
file for a search input component. - The route was already set up in our inventories file as we did when we displayed our items in a list.
- The
export type Inventory
defines a TypeScript type Inventory to represent each inventory item. - Inside the
InventoriesComponent
fucntion, we first retrieved the inventory data loaded by the route and then set a state for global search filtering. - Next up, we defined an array of table columns for displaying inventory data. The
productID
andproductName
columns are sortable alphanumerically. Our image column will display a thumbnail of the product image. - We then initialized TanStack Table with the following:
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, andgetFilteredRowModel()
to enable filtering. - The next thing we did was to set states for
category
andsupplier
filters. We then extracted unique categories and suppliers for filter dropdowns. This then filters inventory data based on selected category and supplier. - We rendered the search input for the global search input and added a button to clear the filtered rows.
- Finally, we render the table using:
- The Header (
thead
) loops throughtable.getHeaderGroups()
to create column headers. - The Body (
tbody
) loops throughtable.getRowModel()
to display inventory data. It then usesflexRender()
to correctly render the cell content.
- The Header (
TanStack Table Search Functionality for the Inventory List
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 theuseEffect
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.
- Next, we created a state variable value and initialized it with
initValue
. WheneverinitValue
changes (e.g., when the parent component updates it), this effect will ensure that the internal state (value) is also updated. - The
useEffect
is used when value changes to start a timer (setTimeout
). After debounce milliseconds (default: 500ms
),onChange(value)
is called. - If
value
changes before the delay is over, theclearTimeout(timeout)
cancels the previous timer to prevent unnecessary function calls (debouncing). This helps to avoid callingonChange
on every keystroke to improve performance. - Finally, we rendered the input field by spreading any additional props (
...props
) onto the<input>
element. Thevalue
state controls thevalue
and theonChange
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.
Adding Inventory Items with TanStack Form
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.
Creating Inventory Form
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:
- First, we import the
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.- The
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.- The main form component
ItemForm
handles inventory item submissions. It take two props:onClose
, which closes the form andonItemAdded
which is a callback for when an item is successfully added. - We then use the
useForm
to initialize the form with default values for product fields,productImage
field set as null initially, andonSubmit
to handle form submission. - We create the inventory entry buy extract
productImage
separately to exclude it from the first request and prepare a payload with other form data. - The
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 inventorydocumentId
. - The next thing we did is to create a
FormData
object and append the selected image. This will send the image to Strapi's/upload
endpoint. - If the upload is successful, it extracts the uploaded image ID. If not, it returns an error.
- We handled the inventory entries for the product id, name, etc differently than the product image. So we have to update the inventory entry to link the uploaded image by performing a
PUT
request to update entry with image ID. - If the update is successful, it logs the response. It calls
onItemAdded
, alerts success, and reloads the page to show the newly added item. And of course, any error is caught and logged. - Moving on to the form UI, the form prevents default form submission and calls
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 theFieldInfo
to show errors.- Other fields (productName, quantity, price, category, supplier) follow a similar structure.
- The
productImage
field uses the<input type="file">
to select an image and callsfield.handleChange()
to update the form state. - The
form.Subscribe
contains buttons for submitting the form, reseting form values, and cancelling submission.
Grant Access Permission for Strapi Images
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.
Update Inventory Page
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:
- First, we'll import the
ItemForm.tsx
file from thecomponents
folder - We'll then create a state to hold the list of inventory items and update dynamically when a new item is added.
- Next, we'll create a
handleItemAdded
function to update theinventoryList
by adding the newly created item. The spread operator (...prev
) will keep existing inventory items intact. We'll also include a defaultproductDetails
property. - Now, we set another state to control whether the item form is visible or hidden and add a button which when clicked, will set
showForm
to true, making the form appear. To render the form when needed, we'll:
Make the
ItemForm
component appear only whenshowForm
is true.Set
onClose
prop to allow closing the form.Include the
onItemAdded
prop to passhandleItemAdded
toItemForm
, enabling it to update the inventory list when a new item is successfully added.
What our form looks like after styling:
Deleting Inventory Item
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:
- First, we'll add a
documentId?
to theexport type Inventory
to act as the unique identifier for the inventory item to be deleted. Set the value to string, like this:
1documentId?: string;
- We'll then create a
handleDelete
function in theroutes/inventories.tsx
file
1// 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 };
- This function takes
documentId
as a parameter, which is the unique identifier for the inventory item to be deleted. - The
const = response...
makes aDELETE
request to http://localhost:1337/api/inventories/{documentId} to remove the specific inventory item from the Strapi backend. - If the request is successful
(response.ok)
, the function updates the UI. - The state
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. - If the response is not ok, an error is thrown. If the request fails (e.g., network issues or server errors), an error message is logged, and an alert notifies the user.
- Finally, we add a delete button in the body of the table to appear in every row of inventory item, like this:
1<button
2 onClick={() => handleDelete(newItem?.documentId)}
3 className="delete-btn"
4>
5 🗑️
6</button>
- When this button is clicked, it triggers
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.
App Demo
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.
GitHub Repo
Want to explore the full code? Check out the full project here in this GitHub Repo.
Conclusion
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.