How to build a Product Hunt Clone - Part 1 of 2

Ashutosh K Singh

In this media jam series, we will build a clone of Product Hunt, a website to share and discover new products with Next.js. In addition, we will use Auth0 for authentication, Airtable for storing product data, and Cloudinary for storing product images.

In part 1, we will configure the initial Next.js app with Auth0, Cloudinary, and Airtable. Then, we will fetch the data from Airtable and display them on the homepage. We will also discuss how to add new products to our app.

If you want to jump right into the code, check out the GitHub Repo here.

You can refer to the second part here: How to build a Product Hunt Clone with Next.js - Part 2 of 2

CodeSandbox

Since the preview in the CodeSandbox is embedded inside another page, you will see the products but you will not be able to log in via the preview. Instead, to explore and play navigate to deployed or live version, https://qc1kx.sse.codesandbox.io/.

How to Setup and Install Next.js

We will use Create Next App to initialize a Next.js project quickly. In your project's root directory, run the following commands in the terminal.

1npx create-next-app product-hunt-clone
2cd product-hunt-clone
3npm run dev

The last command, npm run dev, will start the development server on your system's port 3000.

You can close the server by hitting the CTRL+C in the terminal.

In this tutorial, you will use Tailwind CSS to style your Product Hunt Clone app. Run the following command in the terminal to install Tailwind CSS.

1npm install -D tailwindcss@latest postcss@latest autoprefixer@latest @tailwindcss/forms
2npx tailwindcss init -p

The last command will create a tailwind.config.js and postcss.config.js file in your project's root directory.

Update the tailwind.config.js file like this.

1module.exports = {
2 purge: {
3 content: ["./pages/**/*.js", "./components/**/*.js"],
4 options: {},
5 },
6 darkMode: false, // or 'media' or 'class'
7 theme: {},
8 plugins: [require("@tailwindcss/forms")],
9};
  • @tailwindcss/forms - A @tailwindcss plugin that provides a basic reset for form styles. You will use this to create the form for adding/updating new products.

Update styles/global.css file like this.

1@tailwind base;
2@tailwind components;
3@tailwind utilities;

You can refer to https://tailwindcss.com/docs/guides/nextjs#install-tailwind-via-npm for detailed instructions on setting up a Next.js app with Tailwind CSS.

In this tutorial, you will use Auth0 for all your authentication and authorization needs. In addition, you will use the @auth0/nextjs-auth0 SDK for quick and easy installation and configuration.

Create an account on Auth0.com if you haven't already.

Run the following command in the project's terminal to install @auth0/nextjs-auth0 SDK.

1npm install @auth0/nextjs-auth0

On your Auth0 dashboard, create a Regular Web Application and name it Product Hunt Clone.

Under Settings-->Application URIs, configure the following URLs for your application:

Remember to click on the SAVE CHANGES button afterward.

In your project, create a new file named .env.local by running the following command.

1touch .env.local

Copy and paste the Client ID, Client Secret, and Domain of your application from your Auth0 dashboard inside the .env.local file.

1# A long secret value used to encrypt the session cookie
2AUTH0_SECRET='F(5^&wMh&nDvbr!!&jxJ01TYjh4reUcs'
3
4# The base url of your application
5AUTH0_BASE_URL='http://localhost:3000'
6
7# The url of your Auth0 tenant domain
8AUTH0_ISSUER_BASE_URL='https://YOUR_AUTH0_DOMAIN.auth0.com'
9
10# Your Auth0 application's Client ID
11AUTH0_CLIENT_ID='YOUR_AUTH0_CLIENT_ID'
12
13# Your Auth0 application's Client Secret
14AUTH0_CLIENT_SECRET='YOUR_AUTH0_CLIENT_SECRET'

Create a Dynamic API Route handler at /pages/api/auth/[...auth0].js by running the following command.

1cd pages/api
2mkdir auth
3touch auth/[...auth0].js

Add following code to the [...auth0].js file.

1import { handleAuth } from '@auth0/nextjs-auth0';
2export default handleAuth();

This will create the following urls: /api/auth/login, /api/auth/callback, /api/auth/logout and /api/auth/me.

Wrap your pages/_app.js component in the UserProvider component.

1// pages/_app.js
2import "../styles/globals.css";
3import { UserProvider } from "@auth0/nextjs-auth0";
4
5function MyApp({ Component, pageProps }) {
6 return (
7 <UserProvider>
8 <Component {...pageProps} />
9 </UserProvider>
10 );
11}
12
13export default MyApp;

You will use Cloudinary, a media management platform for web and mobile developers, to store the media related to products like images or thumbnails.

Create a free account on Cloudinary if you haven't already.

After creating the account, head over to your dashboard and create a Product Hunt Clone folder; to store the product's images, take note of your  Cloudinary's Cloud name, API Key, and API Secret.

Paste this Cloud name, API Key and API Secret in your .env.local file along with other credentials.

1NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME = 'YOUR_CLOUDINARY_CLOUD_NAME'
2CLOUDINARY_API_KEY = 'YOUR_CLOUDINARY_API_KEY'
3CLOUDINARY_API_SECRET = 'YOUR_CLOUDINARY_API_SECRET'

Next.js has built-in support for loading environment variables from .env.local into process.env.

By default, all environment variables loaded through .env.local are only available in the Node.js environment. This means that they won't be exposed to the browser. However, using the NEXT_PUBLIC_ prefix exposes the environment variable to the browser. You can read more about it here.

Run the following command in the terminal to install the Cloudinary React SDK and Cloudinary Node.js SDK.

1npm install cloudinary-react cloudinary

The next step is to configure your product's database, i.e., Airtable. Then, finally, create an Airtable account, if you haven't already.

After creating an account, create a base named Product Hunt Clone, and inside this base, create a table named products with the following schema.

  • Name - Name of the product - Single line text
  • Description - Short description of the product - Long text
  • Link - Link to the product website - URL
  • Sub - Unique Identifier to identify the creator of product - Single line text
  • PublicId - Public Id of the Image stored on Cloudinary - Single line text

After creating the schema, make sure to add at least one entry to the table. You can copy the sample data from here. In the next part, you will update the schema to add upvotes of the product.

Head over to your Airtable account settings and copy your API Key.

Head over to https://airtable.com/api and choose the Product Hunt Clone base.

On your base's API documentation page, copy the base ID of the Product Hunt Clone base.

Paste both Airtable API Key and Airtable Base ID in your .env.local file.

1AIRTABLE_API_KEY = ''
2AIRTABLE_BASE_ID = ''

You can refer to the .env.local file of this project here.

Run the following command in the terminal to install the Airtable JS official API client, airtable.

1npm install airtable

There are a few other dependencies that you will need in this project. Run the following command to install them.

1npm install react-dropzone
  • react-dropzone - React hook to create a HTML5-compliant drag-drop zone for files. You can read more about this package here.

How To Create the Navbar Component

In this section, you will create the Navbar component, which will display the application name and a Login/SignUp button.

Run the following command in the project's root directory to create the Navbar.js file inside the components directory.

1mkdir components
2cd components
3touch Navbar.js

Add the following code to the Navbar.js file.

1import React from "react";
2import { useUser } from "@auth0/nextjs-auth0";
3import Link from "next/link";
4
5export default function Navbar() {
6 const { user} = useUser();
7
8 return (
9 <div className="relative bg-white">
10 <div className="max-w-7xl mx-auto px-2 sm:px-3">
11 <div className="flex flex-col md:flex-row justify-between items-center border-b-2 border-gray-100 py-6 md:space-x-10 ">
12 <div className="flex justify-start lg:w-0 lg:flex-1">
13 <Link href="/">
14 <a>
15 <img
16 className="h-10 w-auto sm:h-12"
17 src="https://res.cloudinary.com/singhashutoshk/image/upload/v1622888633/product-hunt-clone/b2beawbhpjcy7kwnipo4.svg"
18 alt=""
19 />
20 </a>
21 </Link>
22 <Link href="/">
23 <a>
24 <h1 className="leading-normal text-3xl md:text-4xl font-serif tracking-tight font-bold ml-1 text-gray-800">
25 <span className="block text-purple-600 xl:inline">
26 Product Hunt Clone
27 </span>
28 </h1>
29 </a>
30 </Link>
31 </div>
32 {user ? (
33 <Link href="/products/insert">
34 <a className="inline-flex font-medium text-gray-500 bg-transparent text-indigo-500 font-semibold px-3 py-2 my-1 border border-indigo-500 rounded">
35 <svg
36 xmlns="http://www.w3.org/2000/svg"
37 className="h-6 w-6 hover:bg-white"
38 fill="none"
39 viewBox="0 0 24 24"
40 stroke="#6366F1"
41 >
42 <path
43 strokeLinecap="round"
44 strokeLinejoin="round"
45 strokeWidth={2}
46 d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
47 />
48 </svg>
49 Add Product
50 </a>
51 </Link>
52 ) : null}
53
54 {user ? (
55 <div className=" flex items-center justify-end md:flex-1 lg:w-0">
56 <Link href="/user/profile">
57 <a className="whitespace-nowrap text-base font-medium text-gray-500 hover:text-gray-900">
58 My Profile
59 </a>
60 </Link>
61
62 <Link href="/api/auth/logout">
63 <a className="ml-8 whitespace-nowrap inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-red-400 hover:bg-red-600">
64 Log Out
65 </a>
66 </Link>
67 </div>
68 ) : (
69 <Link href="/api/auth/login">
70 <a className="ml-8 whitespace-nowrap inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-base font-medium text-white bg-purple-600 hover:bg-purple-700">
71 Login
72 </a>
73 </Link>
74 )}
75 </div>
76 </div>
77 </div>
78 );
79}

You start by importing useUser() hook from @auth0/nextjs-auth0. The useUser() hook will give you the UserProfile object from the server-side session by requesting it from the HandleProfile API Route handler.

The user object from userUser() hook contains the current user's information and defaults to undefined if there is no user. Here is how it looks like.

1{
2 "nickname": "lelouchB",
3 "name": "ASHUTOSH KUMAR SINGH",
4 "picture": "https://avatars.githubusercontent.com/u/45850882?v=4",
5 "updated_at": "2021-06-06T05:52:11.117Z",
6 "email": "ashutoshksingh@outlook.com",
7 "sub": "github|45960883"
8}

The sub in the user object is the unique identifier to identify the creator of the product.

In the Navbar.js file, you use this user object with the ternary operator to either show the Login button or the Logout button. The Login and Logout buttons are links with href equal to /api/auth/login and /api/auth/logout routes.

Along with the Logout button, you show a link, My Profile, which takes the user to /user/profile page. This page shows the user their name and their products. Also, in the Navbar, you create a link to the /products/insert page. This page contains a form to add new products to the database.

You will create the /user/profile page in part 2 and /products/insert page later in this jam.

The next step is to import and use the Navbar component to index.js.

1// index.js
2import Head from "next/head";
3import { useEffect, useState } from "react";
4import Navbar from "../components/Navbar";
5import { useUser } from "@auth0/nextjs-auth0";
6
7export default function Home() {
8 const { user, error, isLoading } = useUser();
9
10 if (isLoading) {
11 return (
12 <div className="mx-auto my-64 text-gray-800 text-center text-3xl">
13 Loading
14 </div>
15 );
16 }
17
18 return (
19 <div className="contianer px-3">
20 <Head>
21 <title>Product Hunt Clone</title>
22 <link rel="icon" href="/favicon.ico" />
23 </Head>
24 <Navbar />
25 </div>
26 );
27}

Start the development server by running the npm run dev command. Next, navigate to http://localhost:3000/ in your browser.

Here is how your app will look.

Click on the Login button and either log in with Google OAuth or register a new user on the Auth0 Login Page.

Here is how the Navbar will look after you have logged in.

The next step is to create a container or a Card component that will be used to display the products with their name, image, and description.

How To Create the Product Component

In this section, you will create the Product component inside the components directory.

Run the following command to create the Product component.

1cd components
2touch Product.js

Add the following command to the Product.js file.

1import React from "react";
2import { Image, Transformation } from "cloudinary-react";
3import { useUser } from "@auth0/nextjs-auth0";
4import { useRouter } from "next/router";
5
6export default function Product({
7 name,
8 id,
9 publicId,
10 description,
11 link,
12 check,
13}) {
14 const router = useRouter();
15 const { user, error, isLoading } = useUser();
16
17 const deleteThisProduct = async (e) => {
18 await fetch("/api/deleteProduct", {
19 method: "DELETE",
20 body: JSON.stringify({ id, publicId }),
21 headers: {
22 "Content-Type": "application/json",
23 },
24 });
25 router.reload();
26 };
27
28 return (
29 <div className="max-w-md mx-auto my-4 bg-white rounded-xl shadow-xl overflow-hidden md:max-w-2xl">
30 <div className="md:flex">
31 <div className="md:flex-shrink-0">
32 <Image
33 className="h-48 w-full object-cover md:w-48"
34 publicId={publicId}
35 cloudName={process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME}
36 alt={name}
37 format="webp"
38 secure
39 >
40 <Transformation width="800" gravity="auto" crop="fill" />
41 </Image>
42 </div>
43 <div className="p-8 w-full">
44 <div className="flex justify-between items-start">
45 <a
46 href={link}
47 className="block mt-1 text-xl font-semibold leading-tight font-medium text-indigo-700 hover:underline"
48 >
49 {name}
50 </a>
51 </div>
52 <p className="mt-2 text-gray-600 w-10/12">{description} </p>
53 {check && (
54 <div className="flex justify-end">
55 <button
56 className="mx-1 h-6 w-6"
57 onClick={() =>
58 router.push({
59 pathname: "/update/[id]",
60 query: {
61 id: id,
62 },
63 })
64 }
65 >
66 <svg
67 xmlns="http://www.w3.org/2000/svg"
68 className="h-6 w-6"
69 fill="none"
70 viewBox="0 0 24 24"
71 stroke="gray"
72 >
73 <path
74 strokeLinecap="round"
75 strokeLinejoin="round"
76 strokeWidth={2}
77 d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
78 />
79 </svg>
80 </button>
81 <button onClick={deleteThisProduct} className="px-1">
82 <svg
83 xmlns="http://www.w3.org/2000/svg"
84 className="h-6 w-6"
85 fill="none"
86 viewBox="0 0 24 24"
87 stroke="gray"
88 >
89 <path
90 strokeLinecap="round"
91 strokeLinejoin="round"
92 strokeWidth={2}
93 d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
94 />
95 </svg>
96 </button>
97 </div>
98 )}
99 </div>
100 </div>
101 </div>
102 );
103}

Most of the above code is Tailwind CSS styling. You use the props passed to this component and display them on the page. Here are the props that are passed to this component.

  • name - Name of the Product
  • id - Id of the product
  • publicId - public_id of the image, used with the Image component from cloudinary-react to display the product's image.
  • description - Short description of the Product.
  • link - URL to the product.
  • check - A boolean. You check whether the current user is the product's creator or not and pass either true or false. It compares the sub in the product with the sub of the current user.

The above code uses the check boolean to show the edit and delete buttons to the creator of the product. The edit button takes the user to /update/[id] page. The id of the product is passed in the query of the route with the router object. You can read more about the router object here.

1<button
2 className="mx-1 h-6 w-6"
3 onClick={() =>
4 router.push({
5 pathname: "/update/[id]",
6 query: {
7 id: id,
8 },
9 })
10 }
11>
12 // SVG Code
13</button>

On the edit or /update/[id] page, you will use the product's id to update it in the Airtable database.

When the delete button is clicked, the deleteThisProduct is triggered, sending a DELETE request to /api/deleteProduct route.

In the request body of this DELETE request, you send both the id and publicId of the image. When a product is deleted, its data should be deleted from both Airtable and Cloudinary.

You will create this API route in the last section.

1const deleteThisProduct = async (e) => {
2 await fetch("/api/deleteProduct", {
3 method: "DELETE",
4 body: JSON.stringify({ id, publicId }),
5 headers: {
6 "Content-Type": "application/json",
7 },
8 });
9 router.reload();
10 };

The router.reload() functions reloads the current URL and is equivalent to window.location.reload().

Here is how the Product component looks like.

How To Fetch and Display the Products

In this section, you will fetch the products from Airtable and display them on the app using the Product component.

Create a new file named api.js inside the lib directory by running the following command.

1mkdir lib
2cd lib
3touch api.js

You will create all the functions to fetch, update or delete records from Airtable in this file.

Add the following code to api.js file.

1const Airtable = require("airtable");
2
3const base = new Airtable({ apiKey: process.env.AIRTABLE_API_KEY }).base(
4 process.env.AIRTABLE_BASE_ID
5);
6
7const table = base("products");
8
9export const getAllProducts = async () => {
10 const data = await table
11 .select({
12 view: "Grid view",
13 })
14 .firstPage();
15 return data.map((record) => {
16 return { id: record.id, ...record.fields };
17 });
18};

You start by creating an instance of airtable and then access the Product Hunt Clone base bypassing the base ID and API key stored in the .env.local file to this instance.

You then access the table products bypassing "products" to the Product Hunt Clone base.

You create and export an asynchronous function named getAllProducts which fetches all the products using the .select({view:"Grid view"}) and .firstPage() methods to be displayed on the landing page. You can read more about these methods on your base's documentation page from where you copied the base ID.

Here is how the data array looks like.

1[
2 Record {
3 _table: Table {
4 _base: [Base],
5 id: null,
6 name: 'products',
7 // Functions
8 },
9 id: 'rec6Jv8UmrQk1ZoMc',
10 _rawJson: {
11 id: 'rec6Jv8UmrQk1ZoMc',
12 fields: [Object],
13 createdTime: '2021-06-04T07:37:01.000Z'
14 },
15 fields: {
16 Sub: 'google-oauth2|1053578586999545372338041',
17 Description: 'Final Space API is a RESTful API based on the animated television show Final Space. ',
18 Name: 'Final Space API',
19 PublicId: 'product-hunt-clone/qfadnlboijexxvuqan08',
20 Link: 'https://finalspaceapi.com/'
21 },
22 // Functions
23 }
24]

You map over the data array to destructure each record in the array to return only the id and the fields.

Here is how the array looks like after destructuring. Less messy and easy to use.

1[{
2 id: 'rec6Jv8UmrQk1ZoMc',
3 Sub: 'google-oauth2|1053578586999545372338041',
4 Description: 'Final Space API is a RESTful API based on the animated television show Final Space. ',
5 Name: 'Final Space API',
6 PublicId: 'product-hunt-clone/qfadnlboijexxvuqan08',
7 Link: 'https://finalspaceapi.com/'
8}]

The next step is to import and use the getAllProducts() function in index.js file.

Update index.js file like this.

1import Head from "next/head";
2import Product from "../components/Product";
3import Navbar from "../components/Navbar";
4import { useUser } from "@auth0/nextjs-auth0";
5import { getAllProducts } from "../lib/api";
6
7export default function Home({ products }) {
8 const { user, error, isLoading } = useUser();
9
10 if (isLoading) {
11 return (
12 <div className="mx-auto my-64 text-gray-800 text-center text-3xl">
13 Loading
14 </div>
15 );
16 }
17
18 return (
19 <div className="contianer px-3">
20 <Head>
21 <title>Product Hunt Clone</title>
22 <link rel="icon" href="/favicon.ico" />
23 </Head>
24 <Navbar />
25 <span className="mb-4"></span>
26 {products.length>0 &&
27 products.map((product) => (
28 <Product
29 key={product.id}
30 id={product.id}
31 name={product.Name}
32 link={product.Link}
33 publicId={product.PublicId}
34 description={product.Description}
35 check={user && product.Sub === user.sub ? true : false}
36 />
37 ))}
38 </div>
39 );
40}
41
42export async function getServerSideProps() {
43 const products = await getAllProducts();
44 return {
45 props: { products },
46 };
47}

In the above code, you create and export async function getServerSideProps(). This function uses the getAllProducts() function to fetch the products from Airtable and pass it as props to the Home component. You can read more about getServerSideProps() function here.

You map over the products array and pass its data to the Product component. Notice the check prop of the Product component. You check whether the sub of product is equal to the sub of the current user.

1check={user && product.Sub === user.sub ? true : false}

Navigate to http://localhost:3000/ in your browser. Here is how your app will look.

How To Add New Products

In this section, you will create the form for adding new products. Run the following command to create the product/insert page.

1cd pages
2mkdir products
3cd products
4touch insert.js

Before proceeding further, copy the code for the insert.js file from GitHub and paste it to the insert.js file.

Raw Link: https://raw.githubusercontent.com/lelouchB/product-hunt-clone-part-1/main/pages/products/insert.js

You might notice that in the insert.js file, you are exporting the AddNewProduct component with the withPageAuthRequired() function. This function protects the page it is used with and redirects any anonymous user to the login page.

You will need to be logged in to access the /products/insert page in simple words, which makes sense to discourage spam inserts in the database. You can read more about this method here.

1export default withPageAuthRequired(function AddNewProduct() {
2 // Code
3 }
4)

In the insert.js file, you are ultimately making a POST request to the /api/createProduct API route with the product's name, link, description, and the data URL of the image.

The next step is to accept data from the user. You can split this task into two parts. The first is to store the textual data, i.e., name, link, description, and the second, to create a Drag N Drop area to accept the image and then create a data URL of that image. You will also show the preview of the image to the user.

  • You initialize three states, namely name, link, and description. These states stores the name, link, and description of the product using the value property and onChange() event handler.
1<input
2 type="text"
3 name="product_name"
4 id="product_name"
5 value={name}
6 onChange={(e) => setName(e.target.value)}
7 autoComplete="given-name"
8 className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
9 required
10/>
  • Creating Drag N Drop is rather tricky compared to storing the textual data. You will use the react-dropzone library installed in the first section for the drag n drop component.
1const { getRootProps, getInputProps } = useDropzone({
2 accept: "image/*",
3 multiple: false,
4 onDrop: (acceptedFiles) => {
5 const file = acceptedFiles[0];
6 const reader = new FileReader();
7 reader.readAsDataURL(file);
8 reader.onloadend = () => {
9 setUploadImage(reader.result);
10 };
11 reader.onerror = () => {
12 console.error("Something has happend.");
13 };
14
15 setFiles(
16 acceptedFiles.map((file) =>
17 Object.assign(file, {
18 preview: URL.createObjectURL(file),
19 })
20 )
21 );
22 },
23});

In the above code, you use the useDropzone hook to configure your dropzone. You set accept property to image only and the multiple property to false, which means you can only upload one image.

Inside the onDrop property, you use the FileReader API's .readAsDataURL() method to convert the image to data URL. This data URL is stored inside the uploadImage state.

Here is how the data URL of an image looks like. This data URL has been trimmed. You can see the original data URL here.

1data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAB2oAAAOMCAYAAACM7zM................

You use the .createObjectURL() method to convert the image to Object URL stored inside the file state and is later used to preview the image.

Here is how the Object URL of an image looks like.

1blob:http://localhost:3000/6d511357-c6fa-43ce-9374-0f5ddb7c5445

This file state is used to preview the selected image to the user.

1<aside className=" flex flex-row flex-wrap justify-center mt-2">
2 <span className="flex min-w-0 overflow-hidden">
3 <img src={file} className="block w-auto h-32 w-32" />
4 </span>
5</aside>

When the Add New Product button is clicked, the handleSubmit function is triggered which sends the data in a POST request to /api/createProduct route.

1const createProduct = async ({ name, description, link, uploadImage }) => {
2 try {
3 await fetch("/api/createProduct", {
4 method: "POST",
5 body: JSON.stringify({ name, description, link, uploadImage }),
6 headers: {
7 "Content-Type": "application/json",
8 },
9 });
10 } catch (err) {
11 console.error(err);
12 }
13};
14
15const handleSubmit = async (e) => {
16 await e.preventDefault();
17 await setName("");
18 await setDescription("");
19 await setLink("");
20 await setUploadImage("");
21 await setFile("");
22 await setLoading(true);
23 await createProduct({ name, description, link, uploadImage });
24 await router.push("/");
25};

While the data is being added to Airtable, the loading state is set to true, which makes the form disappear and a message

Uploading! New Product is being added. is shown to the user. After a successful POST request, the user is redirected to the landing page.

The next step is to create the /api/createProduct route or createProduct.js file inside the pages/api directory. Run the following command in the terminal to create the file.

1touch pages/api/createProduct.js

Add the following code to the createProduct.js file.

1import { withApiAuthRequired, getSession } from "@auth0/nextjs-auth0";
2const cloudinary = require('cloudinary').v2;
3var Airtable = require('airtable');
4
5cloudinary.config({
6 cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
7 api_key: process.env.CLOUDINARY_API_KEY,
8 api_secret: process.env.CLOUDINARY_API_SECRET,
9});
10
11const base = new Airtable({ apiKey: process.env.AIRTABLE_API_KEY }).base(
12 process.env.AIRTABLE_BASE_ID
13);
14
15export const config = {
16 api: {
17 bodyParser: {
18 sizeLimit: "3mb",
19 },
20 },
21};
22
23export default withApiAuthRequired(async function handler(req, res) {
24 const session = getSession(req, res);
25 const sub = await session.user.sub;
26
27 if (req.method !== "POST") {
28 return res.status(405);
29 }
30 const { name, description, link, uploadImage } = req.body;
31
32 const image = await cloudinary.uploader.upload(
33 uploadImage,
34 { folder: "product-hunt-clone" },
35 (error, result) => {
36 if (error) {
37 console.error("An error has occured while uploading the image");
38 }
39 console.log("Image was uploaded successfully");
40 }
41 );
42const public_id = image.public_id;
43
44 base('products').create([
45 {
46 "fields": {
47 "Name": name,
48 "Description": description,
49 "Link": link,
50 "Sub": sub,
51 "PublicId": public_id
52 }
53 }
54 ], function(err, records) {
55 if (err) {
56 console.error(err);
57 return res.status(500).json({ msg: "Something went wrong." });
58 }
59 });
60 return res.status(200).json({ msg: "Product Added" });
61
62});

You might notice that similar to the /product/insert route, this route is also protected, and anonymous users cannot access it. The only difference is that this route is protected with the withApiAuthRequired() function and not the withPageAuthRequired() function. You can read more about the withApiAuthRequired() function here.

You will also notice that in this API route you export a config object from this API route. This limits the request body size to 3mb.

1export const config = {
2 api: {
3 bodyParser: {
4 sizeLimit: "3mb",
5 },
6 },
7};

To store the public_id of the image in Airtable, you first need to upload the image to Cloudinary and then use the public_id sent from Cloudinary to create a new record in Airtable.

You create an instance of cloudinary and pass the Cloud Name, API Key, and API Secret, stored in the .env.local file, to the instance. You then use the uploader.upload() function to upload the data URL of the image inside the product-hunt-clone folder in your Cloudinary account. You store the public_id of the uploaded image in the public_id variable.

You create an instance of airtable similar to the one created in the lib/api.js file and create a new record using create() method.

1base("products").create([
2 {
3 fields: {
4 Name: name,
5 Description: description,
6 Link: link,
7 Sub: sub,
8 PublicId: public_id,
9 },
10 },
11])

One thing to notice is how you are getting the user's sub since it was not sent in the request body along with other data. The sub is accessed using the getSession() function from @auth0/nextjs-auth0 SDK.

1const session = getSession(req, res);
2const sub = await session.user.sub;

Head over to http://localhost:3000/products/insert in the browser or click on Add Product button in the Navbar.

Here is how this page will look.

Add some dummy data and click on Add New Product button.

Once the new product has been added in the Airtable, you will be redirected to the landing page.

How To Delete a Product

You have already created the delete button. which triggers deleteThisProduct() function, in the Product component. The deleteThisProduct function makes a DELETE request to the /api/deleteProduct API route.

Run the following command to create the deleteProduct.js file in the pages/api directory.

1touch pages/api/deleteProduct.js

Add the following code to deleteProduct.js file.

1import { withApiAuthRequired } from "@auth0/nextjs-auth0";
2const cloudinary = require("cloudinary").v2;
3var Airtable = require("airtable");
4
5cloudinary.config({
6 cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
7 api_key: process.env.CLOUDINARY_API_KEY,
8 api_secret: process.env.CLOUDINARY_API_SECRET,
9});
10
11const base = new Airtable({ apiKey: process.env.AIRTABLE_API_KEY }).base(
12 process.env.AIRTABLE_BASE_ID
13);
14
15export default withApiAuthRequired(async function handler(req, res) {
16 if (req.method !== "DELETE") {
17 return res.status(405);
18 }
19 const { id, publicId } = req.body;
20
21 try {
22 const deletedProduct = await base("products").destroy(id);
23 const deleteImageFromCloudinary = cloudinary.uploader.destroy(
24 publicId,
25 function (error, result) {
26 if (error) {
27 console.log(error);
28 }
29 }
30 );
31 return res.status(200).json({ msg: "Product Deleted" });
32 } catch (err) {
33 console.error(err);
34 res.status(500).json({ msg: "Something went wrong." });
35 }
36});

In the above cove, you use the destroy(id) method to delete the record from Airtable.

You use the uploader.destroy() method to destroy or delete the image from Cloudinary. After successful deletion, the Product Deleted message is sent as a response to the frontend.

Conclusion

In this media jam, we configure our initial Next.js app and created Navbar and Product components. We also fetched and displayed the products on the home page. Finally, we made a form to add new products to the database.

In the next part, we will

  • Create the My Profile page.
  • Create the /update/[id] page to update the product.
  • Create the upvote button in the Product component to allow logged-in users to upvote a product.

You can refer to the second part here: How to build a Product Hunt Clone with Next.js - Part 2 of 2

Here are a few resources that you might find helpful.

Happy coding!

Ashutosh K Singh

JavaScript Developer

I'm a JavaScript Developer & Technical Writer. I develop awesome stuff with JavaScript and love to write about them.