Building a shopping cart app with Vercel Stack

Vercel’s recent release of Next.js v13 has created quite a buzz in the developer community. And while the release of Next.js is exciting, I was looking forward to trying out Vercel Serverless Storage. With this new service, developers can harness the robust capabilities of the battle-tested Postgres database without hosting it themselves.

While Postgres is suitable for storing application data that changes frequently and for storing data that seldom changes (but is read more frequently), Vercel released Vercel KV to fulfill all product needs. Vercel KV is a key-value pair that is suitable for storing JSON-like data. In this tutorial, we will take a look at how to set up Vercel Postgres and Vercel KV and build a simple shopping cart app.

Jump ahead:

Setting up Vercel Postgres

Vercel recently introduced its Serverless Postgres SQL, and the hobby tier is free and offers decent data storage and data transfer limits. We will use Vercel Postgres as our database store to build our shopping cart app. Under the hood, Vercel relies on Neon to manage the serverless Postgres database instance.

To get started, create a project via the dashboard. Once created, navigate to the dashboard and click the Storage tab in the nav bar. Then, select the Create Database button. From there, choose Postgres, a database name, and a region. Here’s a visual of that:

Setting Up Vercel Postgres

Tip: Choose a region closest to you for better response times. There aren’t many regions available right now.

To connect your Postgres database, run vercel link in the project folder. Then, run vercel env pull .env.development.local. This will pull all the latest secrets for the development environment. Finally, install the Postgres npm package from Vercel by running npm i @vercel/postgres. If you don’t want to install Vercel CLI globally, you can use npx to run Vercel CLI commands

Implementing KV Storage

To set up a KV store, navigate to the Vercel dashboard, open the Storage tab, and click Create Database. Then, choose Redis KV, enter the necessary information, and hit Create:

Creating the KV Storage

Similar to Postgres, we need to pull down the secrets. So, run the vercel env pull .env.development.local command to pull down the latest secrets from Vercel. We are done with the setup!

Building our Next.js v13 project

To set up a Next.js v13 project, run the npx [email protected] command. After filling in the prompts, a new folder will be created with the app’s name. To run the app, CD into the project folder and run npm run dev. This will start a server locally and serve the Next.js app on port 3000. Navigate to localhost:3000 to see the app in action.

One major change in Next.js v13 is all components are server components by default. This means components are rendered on the server, and the HTML output is sent to the browser. This means these components aren’t interactive. If you try to add a useState Hook in a server-rendered component you will get this error:

Building the Next.js v13 Project

Creating tables

Before we start coding, let’s create a couple of tables that will hold our users, products, and cart data. Let’s first start with a users table. To create this table, open the Data tab. In the text editor, we can put a DB query and run it, as shown below:

CREATE TABLE users(
  id SERIAL,
  email TEXT UNIQUE,
  password TEXT
);

The UNIQUE constraint will make sure that no duplicate accounts are created. Now, let’s create our products table. Enter the following code:

CREATE TABLE products(
  id SERIAL,
  name TEXT,
  price INT,
  description TEXT
);

We now have products and users, so let’s create another table that will hold all the items in a cart for our users. Let’s call it cart_items:

CREATE TABLE cart_items(
    cart_item_id SERIAL,
    product_id INT REFERENCES products(id),
    user_id INT REFERENCES users(id),
    PRIMARY KEY(product_id, user_id)
);

One thing to note is that we cannot create tables that have a reserved keyword as column names. If you try to create a table like the one below, you will see this error:

Creating Tables in Next.js for Vercel Storage

The error message isn’t super accurate, and Lee Robinson (VP DX at Vercel) feels the same:

Next.js Error Response From Vercel

Building the login page

Let’s first start by building the UI. Create a login folder in the app directory and add a page.tsx file inside it. Those familiar with Next.js might know that Next has file system-based routing. This means that our login page is now served under /login path. To check, navigate to localhost:3000/login. You should see a blank page. Here’s the code:

// app/login/page.tsx
"use client";
import  useState  from "react";
import  useRouter  from "next/navigation";
export default function Login() 
  const [email, setEmail] = useState<string>("");
  const [password, setPassword] = useState<string>("");
  const router = useRouter();
  const loginUser = async () => 
    let res = await fetch("/api/login", 
      method: "POST",
      body: JSON.stringify( email, password ),
    );
    const  result  = await res.json();
    if (result) 
      router.replace("/products");
    
  ;
  return (
    <div className="flex flex-col items-center">
      <div>Login with username and password to get started</div>
      <div className="flex flex-col w-1/6 bg-gray-100 p-10 rounded-md">
        <div className="flex flex-col flex-1 mt-3">
          <label htmlFor="password">Email Address</label>
          <input
            type="email"
            id="email"
            className="rounded"
            onChange=(e) => setEmail(e.currentTarget.value)
          />
        </div>
        <div className="flex flex-col flex-1 mt-3">
          <label htmlFor="password">Password</label>
          <input
            type="password"
            id="password"
            className="rounded"
            onChange=(e) => setPassword(e.currentTarget.value)
          />
        </div>
        <div className="text-center">
          <button
            className="bg-slate-950 text-white p-2 rounded mt-3"
            onClick=() => 
              loginUser();
            
          >
            Login
          </button>
        </div>
      </div>
    </div>
  );

A few things to observe here, "use client" at the top of the file makes it explicitly a client-side component. By default, components are server-side components in Next.js v13. By using use client, we tell Next not to render it on the server and let the client (browser) handle the rendering and interactivity part.

We use the useState to capture the email and password here. When the user clicks the login button, we make an API call to the login endpoint, and upon success, we route the user to /products route. We create a router using the useRouter Hook made available by next/navigation.

We also use Tailwind CSS classes to make styling easier. We can choose the Tailwind option for styling when we create a new project via the create-next-app command. Now, let’s move to the /login API endpoint, which authenticates the user.

To start, create an api folder under the app directory and create a login folder inside it. The hierarchy should look like this app/api/login. Inside the login folder, create a file route.ts. This file will be responsible for handling any API requests that are made to /api/login path. Here’s the code:

// app/api/login/route.ts
import  sql  from "@vercel/postgres";
import  NextResponse  from "next/server";
export async function POST(req: Request) 
  const  email, password  = await req.json();
  const  rows  =
    await sql`SELECT * FROM users WHERE email=$email AND password=$password`;
  if (rows.length === 1) 
    const res = new NextResponse(
      JSON.stringify( message: "Successfully logged in", result: true )
    );
    res.cookies.set("email_address", email);
    return res;
  
  return NextResponse.json( message: "Failed to login", result: false );

Here, we accept POST requests made to the login endpoint. In the code above, we get the email and password from the Request body and query the database to see if the email and the password match. If they do, we set a cookie and send a successful response back to the client.

We use the Postgres package from Vercel to connect to the serverless database that we created earlier. It automatically picks up the connection string and creds from the env.development.local file that we pulled using the vercel CLI earlier. We then use the sql method from the package to make a database query. We use the tagged template literal syntax in JavaScript to make the DB query. Here’s more on how tagged template literals work.

For the sake of simplicity, we are comparing plaintext passwords here. We should never store passwords are plaintext strings. We have also skipped password and email validations. Also, we should set a session ID as a cookie value. The session ID will point to the user details when a request comes in.

Making a signup page

Along the same lines, let’s build the signup page. Here’s the code:

// app/signup/page.tsx
"use client";
import  useState  from "react";
export default function Login() 
  const [email, setEmail] = useState<string>("");
  const [password, setPassword] = useState<string>("");
  const loginUser = () => 
    fetch("/api/signup", 
      method: "POST",
      body: JSON.stringify( email, password ),
    );
  ;
  return (
    <div className="flex flex-col items-center">
      <div>Signup with username and password to get started</div>
      <div className="flex flex-col w-1/6 bg-gray-100 p-10 rounded-md">
        <div className="flex flex-col flex-1 mt-3">
          <label htmlFor="password">Email Address</label>
          <input
            type="email"
            id="email"
            className="rounded"
            onChange=(e) => setEmail(e.currentTarget.value)
          />
        </div>
        <div className="flex flex-col flex-1 mt-3">
          <label htmlFor="password">Password</label>
          <input
            type="password"
            id="password"
            className="rounded"
            onChange=(e) => setPassword(e.currentTarget.value)
          />
        </div>
        <div className="text-center">
          <button
            className="bg-slate-950 text-white p-2 rounded mt-3"
            onClick=() => 
              loginUser();
            
          >
            Login
          </button>
        </div>
      </div>
    </div>
  );

The following code is for the signup endpoint:

// app/api/signup/route.ts
import  sql  from "@vercel/postgres";
import  NextResponse  from "next/server";
export async function POST(req: Request) 
  const  email, password  = await req.json();
  try 
    await sql`INSERT INTO users(email, password) VALUES($email,$password);`;
    return NextResponse.json( message: "Added account", result: true );
   catch (e) 
    return NextResponse.json(
      message: "Failed to add account",
      result: false,
    );
  

For the sake of simplicity, I’ve skipped email and password validation. To reiterate, never store passwords in plaintext. Encrypt them using a secret and a randomly generated salt string. To learn more about keeping your Next.js apps secure, check out our guide to authentication.

Writing a middleware

It’s common on many websites to observe that if a user is already logged in and attempts to access the login or signup page, they are automatically redirected to the homepage or their dashboard. We can achieve similar behavior with middleware. A middleware is a function that gets called for an incoming request.

We can redirect, respond or modify headers in these middlewares. For our use case here, we want to check if a logged-in user is navigating to the login or signup route. If yes, then redirect them to the products page. To write a middleware, create a middleware.ts file in the root of your project like this:

import  NextRequest, NextResponse  from "next/server";
export function middleware(request: NextRequest) 
    request.nextUrl.pathname.startsWith("/signup")
  ) 
    if (request.cookies.get("email_address")) 
      return NextResponse.redirect(new URL("/products", request.url));
    
  

Building the products list

Following the same steps as above, we will create a simple product page. Here’s the code:

// app/products/page.tsx
import ProductList from "./components/ProductsList";
export default async function ProductsPage() 
  return (
    <div>
      /* @ts-expect-error Server Component */
      <ProductList />
    </div>
  );



// app/products/components/ProductsList.tsx
import  sql  from "@vercel/postgres";
import Image from "next/image";
import AddToCart from "./AddToCart";
export default async function ProductList() {
  const  rows  = await sql`SELECT * FROM products`;
  return (
    <div className="flex flex-row">
      rows.map(( name, id, price, description ) => (
        <div key=id className="p-2">
          <Image
            src=`
            alt="randomImage"
            width=150
            height=150
          />
          <div className="text-base">name</div>
          <div className="text-sm">description</div>
          <div className="text-xl mb-1">$price</div>
          <AddToCart productId=id />
        </div>
      ))
    </div>
  );



// app/products/components/AddToCart.jsx
"use client";
export default function AddToCart( productId ) 
  const addToCart = async () => 
    let res = await fetch("/api/cart", 
      method: "POST",
      body: JSON.stringify( productId ),
    );
  ;
  return <div onClick=addToCart>Add To Cart</div>;

In the AddToCart component, when the user clicks the add to cart button, it makes an API call to the /cart endpoint. One thing to note here is the ProductsList page is a server-rendered component and imports AddToCart client-side rendered component. So, we can mix server-side and client-side rendered components. Now, let’s quickly take a look at the /carts endpoint:

// app/api/cart/route.ts
import  sql  from "@vercel/postgres";
import  RequestCookie  from "next/dist/compiled/@edge-runtime/cookies";
import  cookies  from "next/headers";
import  NextResponse  from "next/server";
export async function POST(req: Request) 
  const  productId  = await req.json();
  const  value  = cookies().get("email_address") as RequestCookie;
  try 
    const  rows: users  =
      await sql`SELECT id FROM users WHERE email=$value`;
    const user_id = users[0].id;
    const  rows  =
      await sql`INSERT INTO cart_items(user_id, product_id) VALUES($user_id, $productId)`;
    return NextResponse.json( message: "Added item to cart", result: true );
   catch (e) 
    return NextResponse.json( message: "Failed to add", result: false );
  

The endpoint does a few things. First, it reads the email address from the cookie we set when the user logs in. Then, it fetches the user’s ID from the users table. And finally, it creates an entry in the cart_items table for a user and the selected product.

Setting up the profile page

We will store profile data inside Redis KV storage. For the profile, we will have two routes: /profile for viewing the profile data and /profile/edit for editing the profile information. Let’s start with building a profile edit page:

// /app/profile/edit/page.tsx
"use client";
import  useState  from "react";
import  useRouter  from "next/navigation";
export default function EditProfile() 
  const router = useRouter();
  const [name, setName] = useState<string>();
  const [tel, setTel] = useState<string>();
  const updateProfile = async () => 
    let res = await fetch("/api/profile", 
      method: "POST",
      body: JSON.stringify( name, tel ),
    );
    const  result  = await res.json();
    if (result) 
      router.replace("/profile");
    
  ;
  return (
    <div className="flex flex-col w-36 justify-center">
      <div>Edit User Profile</div>
      <input
        type="text"
        placeholder="Name"
        onChange=(e) => setName(e.target.value)
      />
      <input
        type="tel"
        placeholder="Phone Number"
        onChange=(e) => setTel(e.target.value)
      />
      <button
        className="bg-slate-950 text-white p-2 rounded mt-3"
        onClick=() => 
          updateProfile();
        
      >
        Update Profile
      </button>
    </div>
  );

We call the /profile API endpoint when the user clicks the Update Profile button. We send the name and the phone number as a payload to that endpoint. Now, let’s create the /profile API endpoint:

// app/api/profile/route.ts
import  kv  from "@vercel/kv";
import  NextResponse  from "next/server";
import  RequestCookie  from "next/dist/compiled/@edge-runtime/cookies";
import  cookies  from "next/headers";
export async function POST(req: Request) 
  const  name, tel  = await req.json();
  const  value  = cookies().get("email_address") as RequestCookie;
  await kv.hmset(value,  name, tel );
  return NextResponse.json( result: true );

Here, we use kv from @vercel/kv package. We get the name and number from the payload and save it against the Redis KV store. It is stored as a hashmap with a key as the logged-in user’s email address. We get the email address from the cookie we set when the user logged in. Now, let’s fetch this data from the KV store and show it to the user. To show, let’s create a profile page:

// app/profile/page.tsx
import  kv  from "@vercel/kv";
import  cookies  from "next/headers";
export default async function Profile() 
  const cookieStore = cookies();
  const email = cookieStore.get("email_address")?.value;
  const data = await kv.hgetall(email as string);
  console.log(data);
  return (
    <div className="flex flex-col w-32">
      <div>View Profile</div>
      <div>
        data &&
          Object.keys(data).map((key) => (
            <div key=key>
              key 👉 data[key]
            </div>
          ))
      </div>
    </div>
  );
 

The above component is a server-rendered component. So, we can directly access the KV store there. To get the key for the hashmap, we read the email_address cookie value and search for that in the KV store using the hgetall command. We then display that to the user. We can also use the CLI provided by Vercel to run Redis commands and check the data inside the KV store.

Conclusion

Vercel started as a hosting provider for Next.js applications but now provides a full suite of features for developing and deploying apps easily on their platform. Vercel offers a pretty generous hobby tier for the services that we used.

So, if you are looking to build something on the side, try out an idea, or build something just for fun, you might want to try out Vercel Stack. That’s it, folks! Thanks for reading!

LogRocket: Full visibility into production Next.js apps

Debugging Next applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Next.js app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your Next.js apps — .

Leave a Reply

Your email address will not be published. Required fields are marked *