This article will walk you through the steps of creating an e-commerce product information management system (PIM) with the Next.js framework, a Neon Postgres database, and Prisma as the object-relational mapper (ORM).
Next.js is a React framework for building fast and user-friendly full-stack applications. It provides numerous features and tools for building simple and complex web pages.
Prisma is a server-side library that helps developers read and write data to the database intuitively, efficiently, and safely.
Neon is a fully managed serverless Postgres with a generous free tier that offers a scalable and cost-effective solution for data storage, and modern developer features such as branching and bottomless storage.
This article won’t cover cart, checkout, and deployment features but will focus on how to create, read, update, and delete (CRUD) products in the database.
Here is a completed demo of this project.
Prerequisites
You’ll need the following to complete this tutorial:
- Node.js installed on your computer.
- Basic knowledge of React and Next.js
- A GitHub account for (OAUTH) authentication
Getting started
We'll start with installing the Next.js package and other dependencies we'll use for this project. Open the terminal and run the below command to create a next.js starter project.
npx create-next-app neon-ecommerce
The above command will prompt a few questions; choose yes when asked whether to use Tailwind CSS. A new project with the appropriate configurations will be created in the selected directory; in our case, it is called neon-e-commerce.
Next, navigate to the project directory and install @prisma/client, next-auth, and Prisma npm packages with the following command:
cd neon-ecommerce
npm i @prisma/client next-auth && npm i prisma --save-dev
Next, run the following command to launch the app:
npm run dev
Next.js will start a live development server at http://localhost:3000.
Setting up a Neon database
Log in or create a Neon account; Neon provides one free tier account with a limit of one project per account.
Click the “Create project” button and copy the database URL provided by Neon on the next page; this is all we need to connect the database to our project.
Setting up GitHub authentication
Since we'll be using GitHub for authentication, we need to register a new OAuth app on GitHub. This authentication is for admin users who want to create and sell their products, not for customers who wish to buy.
First, sign in to GitHub and go to Settings > Developer Settings > OAuth Apps.
Next, click on the "New OAuth App" button and fill out the form like in the image below:
Learn more about next-auth oauth providers (e.g., Twitter, Google, GitHub, etc.).
After registering the application, copy the client ID and generate a new client secret.
Next, in the root directory of the project, create a .env
file and set up environment variables as seen below:
#.env
DATABASE_URL="<YOUR NEON DATABASE URL>"
GITHUB_ID="<YOUR GITHUB CLIENT ID>"
GITHUB_SECRET="<THE GENERATED CLIENT SECRET>"
NEXTAUTH_URL="http://localhost:3000/api/auth"
NEXTAUTH_SECRET="<RUN *openssl rand -base64 32* TO GENERATE A SECRET>"
Learn more about the next-auth secret and how it works.
Now that we've completed the setup and installation, let's start developing the application.
Building the application
The first thing we'll do is create a component folder in the root of our application and a Header.js
file with the following snippet:
//component/Header.js
import React from "react";
import Link from "next/link";
import { useRouter } from "next/router";
import { signOut, useSession } from "next-auth/react";
const Header = () => {
const router = useRouter();
const isActive = (pathname) => router.pathname === pathname;
const { data: session } = useSession();
// #When there is NO User
let left = (
<div className="left">
<Link legacyBehavior href="/">
<a className="bold" data-active={isActive("/")}>
Neon Ecommerce Shop
</a>
</Link>
</div>
);
let right = null;
if (!session) {
right = (
<div className="right">
<Link legacyBehavior href="/api/auth/signin">
<a data-active={isActive("/signup")}>Log in</a>
</Link>
</div>
);
}
}
return (
<nav>
{left}
{right}
</nav>
);
};
export default Header;
In the snippets above, we:
- Imported the
useRouter
hook fromnext/router
to handle navigating to different routes. - Imported
useSession
fromnext-auth
to show our left and rightnav
when no user exists.
Next, let’s handle our nav
for when there is a logged-in user; add the following snippets to the Header.js
file:
// When there is a user
if (session) {
left = (
<div className="left">
<Link legacyBehavior href="/">
<a className="bold" data-active={isActive("/")}>
Products
</a>
</Link>
<Link legacyBehavior href="/p/own">
<a data-active={isActive("/p/own")}>My Products</a>
</Link>
</div>
);
right = (
<div className="right">
<p>
{session.user.name} ({session.user.email})
</p>
<Link legacyBehavior href="/p/create">
<button>
<a>New Product</a>
</button>
</Link>
<button onClick={() => signOut()}>
<a>Log out</a>
</button>
</div>
);
When a user is logged in, we display the Product
, My Products
, New Product
, and signOut
navigations.
Next, let’s add a Layout.js
file within the component folder, which will manage our page layouts and help us render the Header.js
component throughout the application pages.
//component/Header.js
import React from "react";
import Header from "./Header";
const Layout = (props) => (
<div>
<Header />
<div >{props.children}</div>
</div>
);
export default Layout;
Next, import the Layout.js
component inside the pages/index.js
file and render it to see how it looks.
//pages/index.js
import React from "react";
import Layout from "../components/Layout";
const Products = () => {
return (
<Layout>
<div>Hello from Homepage</div>
</Layout>
);
};
export default Products;
In the browser, our application will look like the image below:
Currently, the login button redirects us to the pages/api/auth
route, which we are yet to develop.
Before we create the authentication route, let's generate our app schema, fill it with placeholder data, and read it so that the homepage looks a little more interesting.
Generating schema, creating and reading data
First, let’s create a prisma
folder in the root of our project and create a schema.prisma
file with the following snippets:
//prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL") // uses connection pooling
}
model Product {
id String @id @default(cuid())
name String?
image String?
price String?
publish Boolean @default(true)
productOwner Owner? @relation(fields: [ownerId], references: [id])
ownerId String?
}
model Owner {
id String @id @default(cuid())
name String?
email String? @unique
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
products Product[]
@@map(name: "owners")
}
Before running the npx p
risma
db
push
command to publish our schema, you can use the format command if you’re not confident everything was formatted correctly.
npx prisma format #formats the schema
npx prisma db push #publishes the schema
After clicking on the table navigation in the neon console after publishing, we’ll see the Product
and Owner
models.
At this stage, the Prisma studio is the fastest way to create data; to launch the studio and add data, run the following command:
npx prisma studio
The studio will launch at http://localhost:5555/
Next, create a few mock products in the studio, each with a proper image URL for the product image. Each product must have an owner, select an existing user in the database, or create a new user record.
Fetching and reading data from database
Now that we have successfully created some product data, let's fetch those products and render them on our homepage.
We must establish a Prisma client instance to enable smooth interaction with our Neon database. In the root of our application, create a lib
folder and a prisma.js
file with the following snippets:
//lib/prisma.js
import { PrismaClient } from "@prisma/client";
let prisma;
if (process.env.NODE_ENV === "production") {
prisma = new PrismaClient();
} else {
if (!global.prisma) {
global.prisma = new PrismaClient();
}
prisma = global.prisma;
}
export default prisma;
Next, let’s create a Product.js
file in the component folder with the below snippets:
//component/Product.js
import Router from "next/router";
import Image from "next/image";
function Product({ product }) {
return (
<div onClick={() => Router.push(`/p/${product.id}`)}>
<div>
<img
width={500}
height={500}
className="w-full"
src={product.image}
alt="item image"
/>
<div>
<div>{product.name}</div>
<p>#{product.price}</p>
</div>
<div>
<span>Add to cart</span>
</div>
</div>
</div>
);
}
export default Product;
We will receive individual products in the snippets above and display their details.
Let's fetch the products from our database and show them on the homepage. Update the index.js
file like below.
//pages/index.js
import prisma from "@/lib/prisma";
import Layout from "@/components/Layout";
import Product from "@/components/Product";
export const getStaticProps = async () => {
const products = await prisma.product.findMany({
where: { publish: true },
include: {
productOwner: {
select: { name: true, email: true },
},
},
});
return {
props: { products },
revalidate: 10,
};
};
const Products = (props) => {
return (
<>
<Layout>
<div className="grid grid-cols-3 gap-2">
{props.products.map((product) => (
<Product key={product.id} product={product} />
))}
</div>
</Layout>
</>
);
};
export default Products;
In the snippets above, we:
- Used the
prisma
instance to fetch our published products, then provided them asprops
in thegetStaticProps()
function to ourProducts()
function. - Looped through the
products
props and used ourProduct
component to render each product.
Clicking on each product card directs us to the /p/${product.id}
route, which doesn’t exist yet. Let's create that route first before we handle authentication.
Inside the pages
directory, create a p
folder and [id].js
file with the following snippet:
//pages/p/[id].js
import Layout from "@/components/Layout";
import Product from "@/components/Product";
import prisma from "@/lib/prisma";
export const getServerSideProps = async ({ params }) => {
const product = await prisma.product.findUnique({
where: {
id: String(params?.id),
},
include: {
productOwner: {
select: { name: true, email: true },
},
},
});
return {
props: product,
};
};
function HandleProducts(props) {
return (
<Layout>
<div className="max-w-4xl ">
<Product product={props} />
</div>
</Layout>
);
}
export default HandleProducts;
In the above snippets, we:
- Got the product
id
from theparams
and fetched the product details using theprisma
instance in thegetServerSideProps()
function. - Displayed the product using our
Product
component inside theHandleProducts()
function.
Now the /p/${product.id}
route is in place! Clicking on any product card takes us to the corresponding page with the specific product information.
Authenticating users with GitHub OAuth provider
Recall that clicking the login button takes us to an uncreated api/auth
route; let's create it now.
Create an auth
folder and a [...nextauth].js
file in the api
folder located inside the pages
directory using the following snippet:
//pages/api/auth/[...nextauth].js
import NextAuth from "next-auth";
import GithubProvider from "next-auth/providers/github";
export const authOptions = {
providers: [
GithubProvider({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
// ...add more providers here
],
secret: process.env.NEXTAUTH_SECRET,
};
export default NextAuth(authOptions);
In the above snippet, we imported GithubProvider
and gave it our GitHub id and GitHub secret variables.
Now, when we click on the login button, we will be directed to a page where a "Sign in with GitHub" button is shown.
We will be redirected to our application after signing in with our credentials.
Notice that our navigation has been updated to include the logged-in user's name, MyProducts
, NewProduct
navigations, and a logout
button.
Adding new products
Next, let's create the New Product page for authenticated users to create and publish their products. First, create a reusable form component to collect the product details. In the components
directory, create a Product-Form
file with the following snippet:
//components/Product-Form.js
import React from "react";
function ProductForm({
productName,
productPrice,
productImage,
publish,
setProductName,
setProductPrice,
setProductImage,
setPublish,
handleSubmit,
handleCancel,
}) {
const checkHandler = (e) => {
e.preventDefault();
setPublish(!publish);
};
return (
<>
<div>
<form onSubmit={handleSubmit}>
{/* FORM INPUTS HERE */}
</form>
</div>
</>
);
}
export default ProductForm;
In the snippet above:
- We destructured the product properties in the
ProductForm
component function and used thepublish
andsetPublish
properties to create acheckHandler()
function. - In the
return()
function, we returned aform
tag and passedhandleSubmit
props to the form’sonSubmit
function.
Next, let’s add the following snippet inside the form
tag.
//components/Product-Form.js
<div>
<div>
<label htmlFor="product-name">Name</label>
<input
id="product-name"
type="text"
value={productName}
onChange={(e) => setProductName(e.target.value)}
placeholder="Product title"
required
/>
</div>
<div>
<label htmlFor="product-price">Price</label>
<input
id="product-price"
type="text"
value={productPrice}
onChange={(e) => setProductPrice(e.target.value)}
placeholder="Amount"
required
/>
</div>
</div>
<div>
<div>
<label htmlFor="product-image">Product URL</label>
<input
id="product-image"
type="text"
value={productImage}
onChange={(e) => setProductImage(e.target.value)}
placeholder="Image URL"
required
/>
</div>
</div>
<div>
<div>
<label>
<input
type="checkbox"
checked={publish}
onChange={checkHandler}
/>
<span>Publish Immediately?</span>
</label>
</div>
<div>
<input type="submit" value="Submit" />
<button onClick={handleCancel} type="button">
Cancel
</button>
</div>
</div>
In the snippet above, we added various input fields for the product data, a check box to determine whether the product should be published immediately, a button to initiate the submit action, and another to cancel the process.
To show the product form in the frontend, create a create.js
file inside the p
directory using the following snippet:
// pages/p/create
import React, { useState } from "react";
import Layout from "@/components/Layout";
import Router from "next/router";
import ProductForm from "@/components/Product-Form";
const CreateProduct = () => {
const [productName, setProductName] = useState("");
const [productPrice, setProductPrice] = useState("");
const [productImage, setProductImage] = useState("");
const [publish, setPublish] = useState(false);
const create = async (e) => {
e.preventDefault();
try {
const body = { productName, productPrice, productImage, publish };
await fetch("/api/product/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
await Router.push("/p/own");
} catch (error) {
console.error(error);
}
};
return (
<Layout>
<ProductForm
handleSubmit={(e) => create(e)}
publish={publish}
setPublish={setPublish}
productImage={productImage}
setProductImage={setProductImage}
productName={productName}
setProductName={setProductName}
productPrice={productPrice}
setProductPrice={setProductPrice}
handleCancel={() => Router.push("/p/own")}
/>
</Layout>
);
};
export default CreateProduct;
In the snippet above:
- We imported the
ProductForm
component and defined state variables to hold our product data when a user enters it in the form. - We also wrote a
create()
function, which collects all product data and sends it as a POST request to the/api/product/create
route, which we'll create shortly. - In the
return()
function, we rendered theProductForm
and passed the state constants and thecreate()
function to it.
When we navigate to the New Product page, we’ll see the form rendered like below.
Now, let's create the route we're sending the product data to; in the api
folder, create a product
folder, a create
folder, and an index.js
file with the following snippet:
//api/product/create/index.js
import { getServerSession } from "next-auth/next";
import { authOptions } from "../../auth/[...nextauth]";
import prisma from "@/lib/prisma";
export default async (req, res) => {
const session = await getServerSession(req, res, authOptions);
const { productName, productPrice, productImage, publish } = req.body;
if (session) {
const result = await prisma.product.create({
data: {
name: productName,
price: productPrice,
image: productImage,
publish: publish,
productOwner: {
connectOrCreate: {
where: {
email: session.user.email,
},
create: {
email: session.user.email,
name: session.user.name,
},
},
},
},
});
res.json(result);
} else {
// Not Signed in
res.status(401);
}
res.end();
};
In the snippet above:
We created a session constant with the getSeverSession hook and destructured
productName
,productPrice
,productImage
, andpublish
from the request body.We checked to see if the user making the request has an active session, and if so, we called the
prisma.
product.create()
method and gave our data, which included theproductName
,productPrice
,productImage
, andpublish
.
Because each product will have an owner, the connectOrCreate
method determines whether the authorized user attempting to create the product has a record in the database and connects the product if a record exists or creates a new user if none exists.
Updating a product
Since users can now successfully create products, there may be a need to edit the product details. Let's implement the update functionality to allow users to update their products.
In the p
directory within pages
, update the [id].js
file like in the below:
//pages/p/[id].js
// OTHER IMPORTS HERE
import ProductForm from "@/components/Product-Form";
// getServerSideProps() HERE
function HandleProducts(props) {
const [isVisible, setIsVisible] = useState(false);
const [productName, setProductName] = useState("");
const [productPrice, setProductPrice] = useState("");
const [productImage, setProductImage] = useState("");
const [published, setPublished] = useState(false);
const { id, publish } = props;
const userHasValidSession = Boolean(session);
const productBelongsToUser = session?.user?.email === props.productOwner?.email;
const updateProduct = async () => {
try {
const body = { productName, productPrice, productImage, published };
await fetch(`/api/product/update/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
await Router.push("/p/own");
} catch (error) {
console.error(error);
}
};
return (
<Layout>
<div>
<div>
{/* PRODUCT COMPONENT HERE */}
{userHasValidSession && productBelongsToUser && (
<div className="inline-flex mt-5 py-3">
<button
onClick={() => setIsVisible(true)}
>
Edit
</button>
</div>
)}
</div>
<div>
{isVisible && (
<ProductForm
handleSubmit={() => updateProduct()}
publish={published}
setPublish={setPublished}
productImage={productImage}
setProductImage={setProductImage}
productName={productName}
setProductName={setProductName}
productPrice={productPrice}
setProductPrice={setProductPrice}
handleCancel={() => setIsVisible(false)}
/>
)}
</div>
</div>
</Layout>
);
}
export default HandleProducts;
In the snippet above, we:
- Imported the
ProductForm
component and defined state constants to keep the new product data as they are entered. - Extracted the ID and
publish
fromprops
returned by thegetServerSideProps()
function. - Created the
userHasValidSession
andproductBelongsToUser
constants to determine whether the user has an active session and whether the product belongs to the user. - The
updateProduct()
function will bundle the updated product details and send them as a PUT request to theapi/product/update/${id}
route we'll create soon. - Showed the Edit button in the
return()
function only if the user has an active session and owns the product; the Edit button sets theisVisible
state constant totrue
. - Displayed the
ProductForm
component only if theisVisible
value istrue
, and we passedupdateProduct()
to the form.
Next, let’s create the api/product/update/${id}
route with the following snippet:
//api/product/update/[id].js
import prisma from "@/lib/prisma";
export default async function handleUpdate(req, res) {
const productId = req.query.id;
const { productName, productPrice, productImage, publish } = req.body;
if (req.method === "PUT") {
const product = await prisma.product.update({
where: { id: productId },
data: {
name: productName,
price: productPrice,
image: productImage,
publish: publish,
},
});
res.json(product);
} else {
throw new Error(
`Updating product with ID: ${productId} was not sucessful`
);
}
}
In the snippet above:
- We imported the
prisma
instance, setproductId
constant equal toreq.query.id
, and destructured the product properties from the request body. - We used the
prisma
instance to update the product, passing theproductId
as the id and the product properties as thedata
.
Now, users can successfully create and update products. But what if they want to publish or unpublish a product without performing a full update? Let's add the publish and unpublish functions next.
Adding publish and unpublish functions
If the product is unpublished, we want to show a publish button; otherwise, we want to offer an unpublish button. Update the [id].js
file inside the p
directory with the following snippet:
//pages/p/[id].js
// IMPORTS HERE
// getServerSideProps() FUNCTION HERE
function HandleProducts(props) {
// STATE CONSTANTS HERE
const { id, publish } = props;
const userHasValidSession = Boolean(session);
const postBelongsToUser = session?.user?.email === props.productOwner?.email;
async function handlePublish(id) {
const body = { publish };
await fetch(`/api/product/publish/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
await Router.push("/p/own");
}
// updateProduct() FUNCTION HERE
return (
<Layout>
<div>
<div>
{/* PRODUCT COMPONENT HERE */}
{userHasValidSession &&
postBelongsToUser &&
(!props.publish ? (
<button onClick={() => handlePublish(id)}>Publish</button>
) : (
<button onClick={() => handlePublish(id)}>Unpublish</button>
))}
{/* EDIT BUTTON HERE */}
</div>
{/* PRODUCTFORM COMPONENT HERE */}
</div>
</Layout>
);
}
export default HandleProducts;
Here:
- We defined the
handlePublish()
function, saved the product's current publish status as thebody
constant, and sent it to the'/api/product/publish/${id}'
route we will define shortly. - The buttons were conditionally rendered after determining whether the user had an active session and whether the product about to be published or unpublished belonged to the stated user.
Next, let’s create the '/api/product/publish/${id}'
route with the following snippet:
//api/product/publish/
import prisma from "@/lib/prisma";
export default async function handleActive(req, res) {
const productId = req.query.id;
const { publish } = req.body;
const product = await prisma.product.update({
where: { id: productId },
data: { publish: !publish },
});
res.json(product);
}
Here:
- We imported our
prisma
instance and destructured thepublish
constant from the request body. - We updated the product using the
prisma
instance and changed thepublish
data to the reverse of what it is.
Now, users can create, update, publish, and unpublish a product; sometimes, a user may want to delete a product completely. Let’s add the delete function.
Deleting a product
//pages/p/[id].js
// IMPORTS HERE
// getServerSideProps() FUNCTION HERE
//Delete Product
async function deleteProduct(id) {
if (confirm("Delete this product?")) {
await fetch(`/api/product/delete/${id}`, {
method: "DELETE",
});
Router.push("/p/own");
} else {
Router.push(`/p/${id}`);
}
}
function HandleProducts(props) {
// STATE CONSTANTS HERE
const { id, publish } = props;
const userHasValidSession = Boolean(session);
const postBelongsToUser = session?.user?.email === props.productOwner?.email;
// handlePublish() FUNCTION HERE
// updateProduct() FUNCTION HERE
return (
<Layout>
<div>
<div>
{/* PRODUCT COMPONENT HERE */}
{/* PUBLISH AND UNPUBLISH BUTTONS HERE */}
{userHasValidSession && postBelongsToUser && (
<div className="inline-flex mt-5 py-3">
<button onClick={() => deleteProduct(id)}>Delete</button>
{/* Edit BUTTON HERE */}
</div>
)}
</div>
{/* ProductForm COMPONENT HERE */}
</div>
</Layout>
);
}
export default HandleProducts;
In the above snippet:
- We constructed the
deleteProduct()
function, which takes the id of the product we want to delete and sends it to the/api/product/delete/${id}
route we'll develop shortly. - We added a Delete button before the Edit button, called the
deleteProduct()
function, and supplied the product'sid
we wanted to delete.
The complete snippet of the pages/p/[id].js
can be found in this GitHub gist.
Next, let’s implement the /api/product/delete/${id}
route with the following snippet:
import prisma from "@/lib/prisma";
export default async function handleDelete(req, res) {
const productId = req.query.id;
if (req.method === "DELETE") {
const product = await prisma.product.delete({
where: { id: productId },
});
res.json(product);
} else {
throw new Error(
`The HTTP ${req.method} method is not supported at this route.`
);
}
}
Here, we:
- We imported our
prisma
instance and created aproductId
, which we set equal to the request query ID. - The
prisma
sample was used to delete the product, with theproductId
as the data.
A user who has successfully authenticated can now add new products and update, publish, and delete any product that belongs to them.
Conclusion
Congratulations! You successfully built a full-stack application with CRUD functionalities with Next.js, Neon, Prisma, and Next-Auth. Combining these tools can help developers create full-stack applications that are efficient and scalable. Next.js provides a structured development framework, Prisma streamlines database interactions, and Neon delivers a cost-effective and scalable database solution. The source code for this project is in this GitHub repository.