Building a CRUD Application with React and NextJS
Learn how to build a basic NextJS 15 and React 19 CRUD application in typescript.

By Raivis Studens

In this tutorial, we'll build a full CRUD (Create, Read, Update, Delete) application using React 19 and Next.js 15 with TypeScript. We're going to be using SWR for data fetching and the native fetch API for HTTP requests. For streamlining form management we'll try out React`s new useActionState hook.
CRUD operations form the foundation of most web applications, allowing users to create, read, update, and delete data. By the end of this tutorial, you'll have built a complete CRUD interface for managing a collection of products.
Setting Up the Project
First, let's create a new Next.js project with TypeScript support:
npx create-next-app@latest
When prompted, select the following options:
Project name: NextJS CRUD Example
TypeScript: Yes
ESLint: Yes
Tailwind CSS: Yes
Code inside a src/
: No
App Router: Yes
Turbopack: Yes
import alias @/*
by default): No
We have enabled option for tailwind css, this is optional, but will make styling much easier.
Now navigate to the project folder and install swr library:
cd next-js-crud-example
npm install swr
For the reference there below is the full project structure, keep in mind that many of the files yet to be added by following the tutorial and are not preset in the initial NextJS project.
next-js-crud-example/
├── app/ # Next.js App Router structure
│ ├── _components/ # Shared components
│ │ ├── DeleteControl.tsx # Component for deleting products
│ │ └── FormProduct.tsx # Reusable product form component
│ ├── create/ # Create product page
│ │ └── page.tsx # Create product page component
│ ├── edit/ # Edit product routes
│ │ └── [id]/ # Dynamic route for editing specific product
│ │ └── page.tsx # Edit product page component
│ ├── types/ # TypeScript type definitions
│ │ └── product.ts # Product-related type definitions
│ ├── util/ # Utility functions
│ │ └── api.ts # API interaction functions
│ ├── globals.css # Global CSS styles
│ ├── layout.tsx # Root layout component
│ └── page.tsx # Home page (product listing)
Simple Read Example - Fetching Data
Let's start with the most basic CRUD operation: Read. We'll create a page that fetches and displays a list of products. For api url we're using mocked endpoint which is configured with Apimimic.
First, create a new file app/types.ts to define our product type and api response type:
app/types.ts
export type ResponseProduct = {
data: Product[];
}
export type Product = {
id: number;
name: string;
price: number;
description: string;
}
export type ProductEditData = Omit<Product, "id">;
We have also defined "ProductEditData" type which is the same as "Product" type, except we use typescript utility "Omit" to omit "id" field as we don't need it when we're going to be adding or editing existing products.
Next, let's modify the main page in app/page.tsx to fetch and display products. Replace the code with following:
app/page.tsx
"use client";
import { useEffect, useState } from "react";
import { Product, ResponseProduct } from "./types";
export default function Home() {
const [products, setProducts] = useState<Product[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const fetchProducts = async () => {
try {
const res = await fetch("https://zsktjgscpewgdpdm.apimimic.com/products");
const responseJSON:ResponseProduct = await res.json();
setProducts(responseJSON.data);
} catch (error) {
setError(error as Error);
} finally {
setIsLoading(false);
}
};
fetchProducts();
}, []);
if (error) {
return <div className="text-red-400">Error: {error.message}</div>;
}
return (
<div className="container mx-auto px-4 py-8 bg-gray-900 min-h-screen">
<h1 className="text-3xl font-bold text-center mb-8 text-purple-400">Products</h1>
<div className="flex justify-center">
<Link href="/create" className="bg-blue-400 hover:bg-blue-600 text-white px-4 py-1 rounded-md mb-4 inline-block">Create Product</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{isLoading ? (
<>
{[...Array(6)].map((_, index) => (
<div key={index} className="w-full h-36 bg-gray-700 rounded-lg animate-pulse"></div>
))}
</>
) : null}
{products.map((product) => (
<div key={product.id} className="bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow border-t-4 border-purple-500">
<div className="p-5">
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold text-purple-300 mb-2">{product.name}</h2>
<Link href={`/edit/${product.id}`}>Edit</Link>
</div>
<p className="text-gray-300 mb-4 line-clamp-2">{product.description}</p>
<p className="text-lg font-bold text-teal-400">${product.price.toFixed(2)}</p>
</div>
</div>
))}
</div>
</div>
);
}
This code sets up a basic React component that fetches products when the component mounts. While the data is loading, it displays a loading message; if there's an error, it shows an error message. Once the data is fetched, it renders a list of products. Once the data is fetched, it renders a list of products. We also added a link to edit page, at the moment it leads to an empty page, we'll add it later.
For the styling we're using utility classes tailwind provides. We're positioning components in a grid and depending on screen size we specify column count.
grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6
Improving with SWR
There is a better way to load data than using useEffect and fetch, let's refactor our code to use SWR. SWR is a data fetching library which provides caching, revalidation, loading states and other features that make data fetching more robust.
Firstly create a new directory and a file app/lib/api.ts for our API functions
app/lib/api.ts
export const apiUrl = 'https://zsktjgscpewgdpdm.apimimic.com';
export const fetcher = async (url: string) => {
const res = await fetch(`${apiUrl}/${url}`);
if (!res.ok) {
throw new Error("Failed to fetch data");
}
return res.json();
};
We have defined base apiUrl, so we don't have to manually specify it in each of our requests. We can also easily change it later.
We also added a wrapper function around native fetch function and will handle parsing JSON data. If you want more bells and whistles, feel free to replace native fetch with axious or other third party libraries.
Now, update our page to use SWR.
app/page.tsx
"use client";
import useSWR from "swr";
import { Product, ResponseProduct } from "./types";
import { fetcher } from "./util/api";
import Link from "next/link";
import DeleteControl from "./_components/DeleteControl";
export default function Home() {
// Using SWR hook instead of useEffect + useState
const { data, error, isLoading } = useSWR<ResponseProduct>(
"products",
fetcher
);
// Derived products from the data
const products: Product[] = data?.data || [];
if (isLoading) {
return <div className="text-white">Loading...</div>;
}
if (error) {
return <div className="text-red-400">Error: {error.message}</div>;
}
return (
<div className="container mx-auto px-4 py-8 bg-gray-900 min-h-screen">
<h1 className="text-3xl font-bold text-center mb-8 text-purple-400">Products</h1>
<div className="flex justify-center">
<Link href="/create" className="bg-blue-400 hover:bg-blue-600 text-white px-4 py-1 rounded-md mb-4 inline-block">Create Product</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{isLoading ? (
<>
{[...Array(6)].map((_, index) => (
<div key={index} className="w-full h-36 bg-gray-700 rounded-lg animate-pulse"></div>
))}
</>
) : null}
{products.map((product) => (
<div key={product.id} className="bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow border-t-4 border-purple-500">
<div className="p-5">
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold text-purple-300 mb-2">{product.name}</h2>
<Link href={`/edit/${product.id}`}>Edit</Link>
</div>
<p className="text-gray-300 mb-4 line-clamp-2">{product.description}</p>
<div className="flex justify-between items-center">
<p className="text-lg font-bold text-teal-400">${product.price.toFixed(2)}</p>
</div>
</div>
</div>
))}
</div>
</div>
);
}
SWR simplifies our component by handling the fetching logic, caching, and revalidation. The useSWR hook accepts a key (the URL in this case) and a fetcher function. It returns the data, error state, and loading state. This means we also are removing error and loading useState hooks.
This covers our read functionality, lets move to rest of the CRUD, which is creating, updating and deleting.
Adding Create Functionality
Now let's add the ability to create new products. We'll create a reusable form component which will be used for both - adding and editing existing products.
app/components/FormProduct.tsx
"use client";
import { useActionState } from "react";
import { ProductFormData } from "../types/product";
interface FormProductProps {
product?: ProductFormData;
onSubmit: (product: ProductFormData) => void;
submitButtonText?: string;
}
export default function FormProduct({
product,
onSubmit,
submitButtonText = "Save Product",
}: FormProductProps) {
const [error, submitProduct, isPending] = useActionState<string | null, FormData>(
async (previousState, formData) => {
try {
await onSubmit({
name: (formData.get('name') as string) ?? '',
price: parseFloat(formData.get('price') !== "" ? formData.get('price') as string : '0'),
description: (formData.get('description') as string) ?? '',
});
return null;
} catch {
return "Failed to create product. Please try again.";
}
},
null,
);
if(error) {
return <div className="text-red-500">{error}</div>;
}
return (
<form action={submitProduct} className="bg-gray-800 p-6 rounded-lg shadow-md">
<div className="mb-4">
<label htmlFor="name" className="block text-purple-300 mb-2">
Product Name
</label>
<input
type="text"
id="name"
name="name"
defaultValue={product?.name || ''}
className="w-full p-2 bg-gray-700 border rounded-md text-white border-gray-600"
/>
</div>
<div className="mb-4">
<label htmlFor="price" className="block text-purple-300 mb-2">
Price ($)
</label>
<input
type="number"
id="price"
name="price"
step="0.01"
min="0"
defaultValue={product?.price.toString() || 0}
className="w-full p-2 bg-gray-700 border rounded-md text-white border-gray-600"
/>
</div>
<div className="mb-6">
<label htmlFor="description" className="block text-purple-300 mb-2">
Description
</label>
<textarea
id="description"
name="description"
rows={4}
defaultValue={product?.description || ''}
className="w-full p-2 bg-gray-700 border rounded-md text-white border-gray-600"
/>
</div>
<button
type="submit"
disabled={isPending}
className="w-full bg-purple-600 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded-md transition-colors disabled:bg-purple-800 disabled:cursor-not-allowed"
>
{isPending ? "Submitting..." : submitButtonText}
</button>
</form>
);
}
The form component takes the initial data, a submit handler, and button text as props.
useActionState
React 19 adds new hook - useActionState, this will help us to manage updating state of the form, as it has built in isPending loading state and can read form data automatically without a need of the old ways of using useState hooks.
In our example we're passing two arguments to useActionState hook
action function - the function which is executing on calling "submitProduct", in our case it reads form data and passed it to the partent component.
initial value of useActionState - we have no need for it, so we pass "null".
[error, submitProduct, isPending] = useActionState<string | null, FormData>
When using useAction state we define two types
string | null - the first type defines the value of the action state, this value is also returned by the action function. In our case we use this as a value for error message.
FormData - This defines the data which is submited to the action function "submitProduct". In our example it's a formData.
Now, update our API functions to include the create method:
app/util/api.ts
import { Product } from '../types';
export async function fetcher(url: string) {
const response = await fetch(url);
if (!response.ok) {
throw new Error('API error');
}
return response.json();
}
export const createProduct = async (product: ProductEditData): Promise<Product> => {
const response = await fetch(`${apiUrl}/products`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(product),
});
if (!response.ok) {
throw new Error('Failed to create product');
}
return response.json();
}
Let's add a new page to create new products:
app/create/page.tsx
"use client";
import { useRouter } from "next/navigation";
import FormProduct from "../_components/FormProduct";
import { createProduct } from "../util/api";
import Link from "next/link";
import type { ProductEditData } from "../types/product";
import { mutate } from "swr";
export default function NewProductPage() {
const router = useRouter();
const createProductHandler = async (product: ProductEditData) => {
await createProduct(product);
mutate("products");
router.push("/");
}
return (
<div className="container mx-auto px-4 py-8 bg-gray-900 min-h-screen">
<div className="max-w-md mx-auto">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-purple-400">Add New Product</h1>
<Link
href="/"
className="bg-gray-700 hover:bg-gray-600 text-white px-4 py-1 rounded-md transition-colors"
>
Back to Products
</Link>
</div>
<FormProduct
onSubmit={createProductHandler}
submitButtonText="Create Product"
/>
</div>
</div>
);
}
The page uses the resuable FormProduct component we made just before which has createProductHandler passed as a prop. Once product is created we mutate the products data with swr mutate function to invalidate the products data and tell swr to reload it from the server. Then we redirect user back to the main page.
That's it, creating functionality should fully work, try it out!
Adding Update Functionality
Next, let's add the ability to update existing products. First, update our API functions. Add this code at the bottom of the file:
app/utils/api.ts
// ... existing code ...
export async function updateProduct(id: number, product: Omit<Product, 'id'>): Promise<Product> {
const response = await fetch(`${apiUrl}/products/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(product),
});
if (!response.ok) {
throw new Error('Failed to update product');
}
return response.json();
}
Now lets add edit page. The same as in a create product page, in the edit page we also use the reusable FormProduct component but this time with product data passed to its props, which will be used as default values in the form.
app/edit/page.tsx
"use client";
import { useParams, useRouter } from "next/navigation";
import FormProduct from "../../_components/FormProduct";
import { fetcher, updateProduct } from "../../util/api";
import Link from "next/link";
import useSWR, { mutate } from "swr";
import { Product, ProductEditData } from "@/app/types/product";
export default function EditProductPage() {
const { id } = useParams();
const router = useRouter();
const { data, error: fetchError, mutate: mutateProduct, isLoading } = useSWR<Product>(
id ? `products/${id}` : null,
fetcher
);
const editProductHanlder = async (productData: ProductEditData) => {
await updateProduct(parseInt(id as string), productData);
mutateProduct();
mutate("products");
router.push("/");
}
if (fetchError || !id) {
return <div className="text-red-400">Error: {fetchError?.message || "Product ID not provided"}</div>;
}
return (
<div className="container mx-auto px-4 py-8 bg-gray-900 min-h-screen">
<div className="max-w-md mx-auto">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-purple-400">Edit Product</h1>
<Link
href="/"
className="bg-gray-700 hover:bg-gray-600 text-white px-4 py-1 rounded-md transition-colors"
>
Back to Products
</Link>
</div>
{isLoading ? (
<div className="w-full max-w-md h-80 bg-gray-700 rounded-lg animate-pulse"></div>
):null}
{data ? (
<FormProduct
product={data}
onSubmit={editProductHanlder}
submitButtonText="Update Product"
/>
):null}
</div>
</div>
);
}
We've added useSWR hook to load specific product from the api. You can see caching in action here if you open the same product page second time, product data will be displayed instantly from the cache instead of loading.
In editProductHanlder we call swr`s mutate functions to invalidate the data - similarly as in create page, we're also invalidating the data of current data of the product.
Adding Delete Functionality
Finally, let's add the ability to delete products. Start with adding deleteProduct function:
app/utils/api.ts
import { Product } from '../types';
// ... existing code ...
export const deleteProduct = async (id: number): Promise<boolean> => {
const response = await fetch(`${apiUrl}/products/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete product');
}
return true;
}
Now create a new component which will handle delete logic:
app/_components/DeleteControl.tsx
import { startTransition, useActionState } from "react";
import { deleteProduct } from "../util/api";
import { mutate } from "swr";
const DeleteControl = ({ productId }: { productId: number }) => {
const [deleteError, performDelete, isDeletingProduct] = useActionState<string | null, number>(
async (previousState: string | null, id: number) => {
try {
await deleteProduct(id);
return null;
} catch {
return "Failed to delete product. Please try again.";
} finally {
mutate('products');
}
},
null,
);
return (
<>
{deleteError ? (
<div className="bg-red-500 text-white p-3 rounded-md mb-4 mx-auto max-w-md">
{deleteError}
</div>
): null}
<button
onClick={() => {
if (!confirm("Are you sure you want to delete this product?")) {
return null;
}
startTransition(() => performDelete(productId));
}}
disabled={isDeletingProduct}
className={`${
isDeletingProduct
? "bg-gray-600"
: "bg-red-400 hover:bg-red-500"
} text-white px-3 py-1 rounded-md transition-colors`}
>
{isDeletingProduct ? "Deleting..." : "Delete"}
</button>
</>
);
};
export default DeleteControl;
It works similarly to create and edit components and uses useActionState as well. We're also using browser`s built in confirm window for simplicity sake, but this can be improved later on.
Now lets update our page to include delete component for each product:
app/page.tsx
"use client";
import useSWR from "swr";
import type { Product, ResponseProduct } from "./types/product";
import { fetcher } from "./util/api";
import Link from "next/link";
import DeleteControl from "./_components/DeleteControl";
export default function Home() {
// Using SWR hook instead of useEffect + useState
const { data, error, isLoading } = useSWR<ResponseProduct>(
"products",
fetcher
);
// Derived products from the data
const products: Product[] = data?.data || [];
if (isLoading) {
return <div className="text-white">Loading...</div>;
}
if (error) {
return <div className="text-red-400">Error: {error.message}</div>;
}
return (
<div className="container mx-auto px-4 py-8 bg-gray-900 min-h-screen">
<h1 className="text-3xl font-bold text-center mb-8 text-purple-400">Products</h1>
<div className="flex justify-center">
<Link href="/create" className="bg-blue-400 hover:bg-blue-600 text-white px-4 py-1 rounded-md mb-4 inline-block">Create Product</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map((product) => (
<div key={product.id} className="bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow border-t-4 border-purple-500">
<div className="p-5">
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold text-purple-300 mb-2">{product.name}</h2>
<Link href={`/edit/${product.id}`}>Edit</Link>
</div>
<p className="text-gray-300 mb-4 line-clamp-2">{product.description}</p>
<div className="flex justify-between items-center">
<p className="text-lg font-bold text-teal-400">${product.price.toFixed(2)}</p>
<DeleteControl productId={product.id} />
</div>
</div>
</div>
))}
</div>
</div>
);
}
We've added a Delete button to each product entry, this was the final step. Open the page and try it out!
Conclusion
Congratulations! You've successfully built a completely functional CRUD application using Next.js 15 and React 19 with TypeScript which allows to manage a list of products. The patterns used here can be used as a foundation and applied to real-wrold scenarious. You leveraged React 19's new useActionState hook for form handling, implemented efficient data fetching with SWR for automatic caching and revalidation, and built a type-safe application using TypeScript.
Full implementation of this tutorial can be found on GitHub.
Steps for Improvement
This tutorial shows the basic implementation of CRUD functionality in NextJS, React and SWR. There are several steps which would help to improve functionality and make it more production ready, feel free to improve on it. Here are few suggestions where you can expand the example:
Adding input field validations and error messages.
Implementing useTransition to render part of the UI in background while form data is being processed
Consider moving actions to server side functions.
Preloading fetch data in NextJS
Neater alert and confirmation popups
Toast messages on adding, deleting or editing products