loading...
Cover image for Building a Product Hunt clone app using Hasura and Next.js

Building a Product Hunt clone app using Hasura and Next.js

bnevilleoneill profile image Brian Neville-O'Neill Originally published at blog.logrocket.com on ・23 min read

Written by Nirmalya Ghosh✏️

This tutorial will demonstrate how to create a clone of product curation app Product Hunt using Hasura and Next.js. We’ll show you how to conduct authentication and authorization using Hasura and how to leverage GraphQL to fetch and write data from a Next.js frontend application.

We’ll discuss these tasks within the context of three applications:

  1. Frontend using Next.js
  2. Backend using Hasura (which uses the Postgres database)
  3. Authentication using Node.js

The whole project is available on GitHub.

What are Next.js and Hasura?

I’ve been using Next.js for over a year and have been satisfied with the overall developer experience because it supports many exciting features, such as server-side rendering, out of the box.

Hasura is an engine that autogenerates GraphQL APIs, as well as migration files for creating, updating, and deleting tables and columns in the Postgres database.

LogRocket Free Trial Banner

Getting started with Hasura

Let’s start by bootstrapping our backend application.

First, we’ll need to install the Hasura CLI. Once it’s installed, we can initialize the directory for storing Hasura GraphQL migrations.

hasura init --directory backend --endpoint http://localhost:8080 --admin-secret secret

For more details about the flags --directory, --endpoint, and --admin-secret, see the official Hasura documentation.

Initializing a Hasura Application Using Hasura CLI

The above command will create a backend directory.

Initializing the Backend Application

The config.yam file will contain the following.

// backend/config.yaml

admin_secret: secret
endpoint: http://localhost:8080

We’ll use Docker to install Hasura. According to the official docs, it supports Mac, Windows, and Linux. We’ll need to create a new file called Dockerfile inside our backend directory.

// backend/Dockerfile

FROM hasura/graphql-engine:v1.0.0
RUN mkdir /backend-app
WORKDIR /backend-app
COPY . /backend-app/

Let’s also create a .env file to store all the credentials for Hasura.

// backend/.env

HASURA_GRAPHQL_DATABASE_URL=postgres://postgres:@db:5432/postgres
HASURA_GRAPHQL_ENABLE_CONSOLE=false
HASURA_GRAPHQL_MIGRATIONS_DIR=./migrations
ENDPOINT=http://localhost:8080
HASURA_GRAPHQL_ADMIN_SECRET=secret
HASURA_GRAPHQL_JWT_SECRET={"type": "RS256", "key": ""}

It’s always a good idea to store credentials in environment files. We’ll add the HASURA_GRAPHQL_JWT_SECRET after we initialize our authentication application.

That’s all the coding we need to do for our backend application. The migration files will be generated automatically when we add tables and columns to our database.

Getting started with Next.js

We’ll create the Next.js application inside a new frontend directory. We’ll use Docker for Next.js as well.

Next.js has many examples to bootstrap our application. We’ll use the with-docker example.

yarn create next-app --example with-docker with-docker-app

Initializing a Next.js Application Using next-app

The above command will generate a frontend directory.

Initializing the Frontend Application

Let’s update the contents of Dockerfile with the follwing.

// frontend/Dockerfile

FROM mhart/alpine-node:11.1.0
RUN mkdir /frontend-app
WORKDIR /frontend-app
RUN apk update && apk upgrade && apk add --no-cache bash git
COPY . /frontend-app/
RUN yarn install
EXPOSE 3000
CMD ["yarn", "dev"]

Building an authentication app

We’ll set up our authentication application using JWT, Hasura claims, and multiple roles. We’ll use this repository to initialize our application according to the following steps.

Step 1. Create a new directory called authentication

mkdir authentication

Step 2. Clone the repository

git clone https://github.com/hasura/graphql-engine

Step 3. Visit the passportjs-jwt-roles directory

cd graphql-engine/community/boilerplates/auth-servers/passportjs-jwt-roles

Step 4. Move this directory to the authentication directory we just created

mv * ../../../../../authentication

Step 5. Create a .env file inside the authentication directory with the following content

//authentication/.envDATABASE_URL=postgres://postgres:@db:5432/postgres

Step 6. Install the dependencies

yarn install

Step 7. Generate the RSA keys

openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout > public.pem

Step 8. Print the keys in an escaped format

awk -v ORS='\n' '1' public.pem 

Step 9. Add the output into the HASURA_GRAPHQL_JWT_SECRET key present in the .env file of our backend application.

// backend/.env

...
HASURA_GRAPHQL_JWT_SECRET={"type": "RS256", "key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvdJnVbZrrfYGGrnZTQwq\nlOZGonXh5h6yDqN7Np1KRmz8EyZKulKn186StNt2TZJPyYdAcAH6vnB05LUS47VF\nv9eIaAn8952BnJe0NZ9o+90k03wADORNzzeNqmJWGn2YbBiJHNLDt21Zn9dbOWOE\nKjO7eWOuiKEYEyC/OW9+p+Z05ahCZvmWVzNu9ylnHb8wqlFpwaGIEBn4Cm5/C7Zs\nj6BgggZOKp0GN6XNsc8+4zU1h2CpcCjyDJcNN1mGTMSLQHNOLAI4FY/qLwJcSomf\n8d7vTn+8EsJl+Ur+73qIYKpGoGiJw6Y2qpviRCttR8QF21QlIynz71YZTMl1SBmV\nkwIDAQAB\n-----END PUBLIC KEY-----\n"}

Our authentication application should have the following structure.

Initializing Our Authentication Application

Restructuring the application tree

If we view our application structure from the root, we’ll see the following.

Structure of Our Applications

Let’s remove the graphql-engine directory from the root since we won’t need that anymore.

rm -rf graphql-engine

Next, we’ll create a new docker-compose.yml file, which will be responsible for composing our applications with the following content.

// docker-compose.yml

version: "3"
services:
  backend:
    build:
      context: ./backend
    depends_on:
      - db
    env_file: ./backend/.env
    ports:
      - 8080:8080
    volumes:
      - ./backend:/backend-app
  frontend:
    build:
      context: ./frontend
    command: yarn dev
    depends_on:
      - backend
      - authentication
    volumes:
      - ./frontend:/frontend-app
      - ./frontend/package.json:/frontend-app/package.json
      - ./frontend/yarn.lock:/frontend-app/yarn.lock
    ports:
      - "3000:3000"
  authentication:
    build:
      context: ./authentication
    command: yarn start
    env_file: ./authentication/.env
    depends_on:
      - db
    ports:
      - "3030:3030"
    volumes:
      - ./authentication:/authentication-app
      - ./authentication/package.json:/authentication-app/package.json
      - ./authentication/yarn.lock:/authentication-app/yarn.lock
  db:
    image: postgres:11.3-alpine
    volumes:
      - db_data:/var/lib/postgresql/data
volumes:
  db_data:

Our application tree should look like this:

Tree Structure of Our Applications

Starting our applications using Docker

Once Docker is installed and running on our system, we can run the following command to start our applications.

docker-compose up

See the Docker documentation for more information about the above command.

We can check whether all the containers are up and running using the docker-compose ps command.

View Running Docker Containers

Note that the name of the containers can be different.

Running migrations to create user data in our database

In our authentication application, three migrations are present inside the migrations directory:

Migrations for Our Authentication Application

To run these migrations, we will have to SSH into our authentication container. We can find out the names of all our containers by running the following command.

docker-compose ps

View Running Docker Containers

To SSH into the authentication container, we need to run the following command.

docker exec -it authentication_1 /bin/bash

Once inside the container, we can run the migrations using the following command.

bash-4.4# knex migrate:latest

It’ll run all the migrations from the authentication application.

Running Migrations for Our Authentication Application

Now let’s look at how the above three migrations modified our database. After we exit from the container (using the command exit), we can go to our backend directory where we’ll run the following command to run the Hasura console.

hasura console

This will open the Hasura console on http://localhost:9695/. If we go to the Data tab, we’ll be able to see five untracked tables.

Viewing Our Hasura Schema

We need to click the Track button next to role, user, and user_role. These weren’t tracked because they weren’t exposed through the GraphQL API.

Next, we’ll click the Track All button next to Untracked foreign-key relations to track all relationships. We can view the relationships of each table from the Relationships tab inside each table.

Tracking Foreign-Key Relations

Relationships of the user_role Table

We can now view all the tables our migrations created.

The role table has two columns:

  1. id (uuid, primary key, unique; default: gen_random_uuid())
  2. name — (character varying, unique)

The user table has five columns:

  1. id — (uuid, primary key, unique; default: gen_random_uuid())
  2. username — (character varying, unique)
  3. password — (character varying)
  4. created_at — (timestamp with time zone, default: now())
  5. active — (boolean, nullable, default: true)

The user_role table has three columns:

  1. id — (uuid, primary key, unique, default: gen_random_uuid())
  2. role_id — (uuid, nullable)
  3. user_id — (uuid, nullable)

Now if we visit the Hasura GraphQL Explorer, we’ll be able to query our database using GraphiQL.

Checking GraphQL Data Using GraphiQL

Since we don’t have any user data, the response is an empty user array. It’s interesting to note that if we don’t send the x-hasura-admin-secret key (whose value is secret) with the request header, we won’t be able to query any data.

Sending x-hasura-admin-secret in the Request Header Is Mandatory

That’s because we’ve set the value of HASURA_GRAPHQL_ADMIN_SECRET to secret in the .env file as a part of securing our GraphQL endpoint.

Securing Our API Endpoints Using HASURA_GRAPHQL_ADMIN_SECRET

Also, we need to remember that any changes we make through the Hasura console will create migrations inside our migration directory, which is present in the backend application’s directory.

Hasura Console Automatically Generates Migration Files

Authenticating users with our authentication app

If we follow our authentication repository’s documentation, we can initiate the following CURL request to create a new user.

curl -H "Content-Type: application/json" \
     -d'{"username": "admin1", "password": "pass", "confirmPassword": "pass"}'\
     http://localhost:3030/signup

We’ll get the following response.

Response for Creating a New User in Hasura

We can also do a similar CURL request to log in an existing user.

curl -H "Content-Type: application/json" \
     -d'{"username": "admin1", "password": "pass"}' \
     http://localhost:3030/login

We’ll get the following response.

Response for Logging In an Existing User in Hasura

Integrating the authentication application with our frontend application

Let’s connect our Next.js application with our authentication application. The frontend application is running on http://localhost:3000/.

We’ll be using Chakra UI to build our frontend application and Nookies to handle cookies. To install them, we need to SSH into our frontend container.

docker exec -it frontend_1 /bin/bash

Once we’re inside the container, we can install the package using the following command.

bash-4.4# yarn add @chakra-ui/core @emotion/core @emotion/styled emotion-theming nookies

We’ll need to create a new file named _app.js inside the pages directory with the following content.

// frontend/pages/_app.js

import React from 'react'
import NextApp from 'next/app'
import { ThemeProvider, CSSReset, theme } from '@chakra-ui/core'

class App extends NextApp {
  render() {
    const { Component } = this.props
    return (
      <ThemeProvider theme={theme}>
        <CSSReset />
        <Component />
      </ThemeProvider>
    )
  }
}

export default App

Let’s add the the Button component in Chakra UI to our index page.

// frontend/pages/index.js

import React from 'react'
import { Box, Button } from "@chakra-ui/core";

const IndexPage = () => {
  return (
    <Box
      w="100%"
      minH="100vh"
      p={4}
      d="flex"
      alignItems="center"
      justifyContent="center"
    >
      <Button>Hello World!</Button>
    </Box>
  )
}

export default IndexPage

Landing Page of Our Frontend Application

Now let’s create the sign-up form. We’ll have three fields: Username, Password and Confirm Password.

// frontend/components/forms/sign-up.js

import React, { useState } from "react";
import {
  Alert,
  AlertIcon,
  Box,
  Button,
  FormControl,
  FormLabel,
  FormHelperText,
  Input
} from "@chakra-ui/core";
import { setCookie } from "nookies";

const SignUpForm = () => {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [confirmPassword, setConfirmPassword] = useState("");
  const [error, setError] = useState(null);

  const handleSubmit = async e => {
    e.preventDefault();

  const response = await fetch("http://localhost:3030/signup", {
      method: "POST",
      body: JSON.stringify({
        username,
        password,
        confirmPassword
      }),
      headers: {
        "Content-type": "application/json; charset=UTF-8"
      }
    });

    const data = await response.json();

    if (response.status !== 200) {
      setError("Something went wrong! Please try again.");
    } else {
      setCookie({}, "X-Hasura-User-Id", data.id, {
        maxAge: 30 * 24 * 60 * 60,
        path: "/"
      });
      setCookie({}, "token", data.token, {
        maxAge: 30 * 24 * 60 * 60,
        path: "/"
      });
    }
  };

  const errorsNode = () => {
    if (!error) return false;
    return (
      <Alert status="error" mb={8} rounded="md" variant="left-accent">
        <AlertIcon />
        {error}
      </Alert>
    );
  };

  return (
    <Box
      w="100%"
      minH="100vh"
      p={4}
      d="flex"
      alignItems="center"
      justifyContent="center"
    >
      <Box w="500px" p={8} bg="gray.50" rounded="md">
        {errorsNode()}
        <FormControl mb={8}>
          <FormLabel htmlFor="username">Username</FormLabel>
          <Input
            type="text"
            id="username"
            aria-describedby="john"
            value={username}
            onChange={e => setUsername(e.target.value)}
          />
        </FormControl>
        <FormControl mb={8}>
          <FormLabel htmlFor="password">Password</FormLabel>
          <Input
            type="password"
            id="password"
            aria-describedby="*****"
            value={password}
            onChange={e => setPassword(e.target.value)}
          />
        </FormControl>
        <FormControl mb={8}>
          <FormLabel htmlFor="confirm-password">Confirm Password</FormLabel>
          <Input
            type="password"
            id="confirm-password"
            aria-describedby="*****"
            value={confirmPassword}
            onChange={e => setConfirmPassword(e.target.value)}
          />
          {!!confirmPassword && confirmPassword !== password && (
            <FormHelperText id="confirm-password">
              Passwords do not match
            </FormHelperText>
          )}
        </FormControl>
        <FormControl>
          <Button bg="#764ABC" color="white" onClick={handleSubmit}>
            Sign Up
          </Button>
        </FormControl>
      </Box>
    </Box>
  );
};

export default SignUpForm;

We need to import the form in our index page.

// frontend/pages/index.js

import React, { Component } from "react";
import SignUpForm from "../components/forms/sign-up";

export default class IndexPage extends Component {
  render() {
    return <SignUpForm />;
  }
}

The above code will generate the following form on http://localhost:3000/.

Sign-Up Form Using Chakra UI

Let’s take a closer look at the handleSubmit function of our SignUpForm component.

First, we’ll initiate a POST request to http://localhost:3030/signup, which is our sign-up API endpoint. If we get a success response from our API, that means we’re storing the id and token of the user from the response in cookies. If we check our browser cookies, we’ll see that two cookies are now set in our browser.

Setting Cookies for the User From the Response

The X-Hasura-User-Id cookie will be necessary to authorize users in Hasura. We can also store the type of user, but that’s not necessary in our application. We’ll use this cookie while building authorization in the backend application.

Let’s create a similar sign-in form with only two fields: Username and Password .

// frontend/components/forms/sign-in.js

import React, { useState } from "react";
import {
  Alert,
  AlertIcon,
  Box,
  Button,
  FormControl,
  FormLabel,
  Input
} from "@chakra-ui/core";
import { setCookie } from "nookies";

const SignInForm = () => {
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState(null);

  const handleSubmit = async e => {
    e.preventDefault();

    const response = await fetch("http://localhost:3030/login", {
      method: "POST",
      body: JSON.stringify({
        username,
        password
      }),
      headers: {
        "Content-type": "application/json; charset=UTF-8"
      }
    });

    const data = await response.json();

    if (response.status !== 200) {
      setError("Something went wrong! Please try again.");
    } else {
      setCookie({}, "X-Hasura-User-Id", data.id, {
        maxAge: 30 * 24 * 60 * 60,
        path: "/"
      });
      setCookie({}, "token", data.token, {
        maxAge: 30 * 24 * 60 * 60,
        path: "/"
      });
    }
  };

  const errorsNode = () => {
    if (!error) return false;
    return (
      <Alert status="error" mb={8} rounded="md" variant="left-accent">
        <AlertIcon />
        {error}
      </Alert>
    );
  };

  return (
    <Box
      w="100%"
      minH="100vh"
      p={4}
      d="flex"
      alignItems="center"
      justifyContent="center"
    >
      <Box w="500px" p={8} bg="gray.50" rounded="md">
        {errorsNode()}
        <FormControl mb={8}>
          <FormLabel htmlFor="username">Username</FormLabel>
          <Input
            type="text"
            id="username"
            aria-describedby="john"
            value={username}
            onChange={e => setUsername(e.target.value)}
          />
        </FormControl>
        <FormControl mb={8}>
          <FormLabel htmlFor="password">Password</FormLabel>
          <Input
            type="password"
            id="password"
            aria-describedby="*****"
            value={password}
            onChange={e => setPassword(e.target.value)}
          />
        </FormControl>
        <FormControl>
          <Button bg="#764ABC" color="white" onClick={handleSubmit}>
            Sign In
          </Button>
        </FormControl>
      </Box>
    </Box>
  );
};

export default SignInForm;

Let’s import that form in our login page.

// frontend/pages/login.js

import React, { Component } from "react";
import SignInForm from "../components/forms/sign-in";

export default class SignInPage extends Component {
  render() {
    return <SignInForm />;
  }
}

Now if we visit http://localhost:3000/login, we should be able to see our login form.

SignInForm Component

Now, let’s create another component where we’ll show a list of products. Currently, we don’t have any products. So let’s create some.

// frontend/components/lists/products.js

import React from "react";
import { Grid, Box } from "@chakra-ui/core";

const ProductsList = () => {
  return (
    <Box
      w="100%"
      minH="100vh"
      p={4}
      d="flex"
      alignItems="center"
      justifyContent="center"
    >
      <Box w="800px" p={8} bg="gray.50" borderRadius="sm">
        <Grid
          templateColumns={[
            "repeat(1, 1fr)",
            "repeat(1, 1fr)",
            "repeat(2, 1fr)"
          ]}
          gap={8}
        >
          <Box w="100%" h={24} bg="#764ABC" rounded="md" />
          <Box w="100%" h={24} bg="#764ABC" rounded="md" />
          <Box w="100%" h={24} bg="#764ABC" rounded="md" />
          <Box w="100%" h={24} bg="#764ABC" rounded="md" />
          <Box w="100%" h={24} bg="#764ABC" rounded="md" />
          <Box w="100%" h={24} bg="#764ABC" rounded="md" />
        </Grid>
      </Box>
    </Box>
  );
};

export default ProductsList;

We’ll show these components on the products page.

// frontend/pages/products/index.js

import React, { Component } from "react";
import ProductsList from "../components/lists/products";

export default class ProductsPage extends Component {
  render() {
    return <ProductsList />;
  }
}

If we visit http://localhost:3000/products, we should now see the following screen.

Products List Component

For now, we’re just showing placeholder content instead of the products themselves. The next step is to create a table to store the products in our backend application.

Creating tables for storing our products in Hasura

Let’s build the feature to store and retrieve products from our database using Hasura.

If we change the directory to our backend application and run the hasura console command, the console will be up and running at http://localhost:9695/.

Hasura Console Up and Running on http://localhost:9695/

Let’s click on the Data tab and add a new table called product, which will have the following six columns.

    1. id (uuid, primary key, unique, default: gen_random_uuid())
    2. name (character)
    3. description (character)
    4. created_at (timestamp with time zone, default: now())
    5. updated_at (timestamp with time zone, default: now())
    6. maker_id (uuid)

Product Model in Hasura

We’ll also need to add a relationship between the product and the maker (the user who added the product). We’d use this relationship to authorize editing of that item.

To establish the relationship between the product and user model, we need to do the following:

  1. Click on the Relationships tab inside the product model
  2. Click the Configure button below Add a new relationship manually
  3. Select Object Relationship from the Relationship Type dropdown
  4. Type “maker” as the Relationship Name
  5. Select public from the Reference Schema dropdown
  6. Select user as the Reference Table
  7. Select maker_id from the From dropdown and id from the To dropdown

We’ve now defined a relationship between the product and user models.

Relationship Between Product and User Models

You can find more information about Relationships in the Hasura documentation.

Now let’s define a new relationship between the User and Product models.

  1. Click on the Relationships tab inside the user model
  2. Click the Configure button below Add a new relationship manually
  3. Select Array Relationship from the Relationship Type dropdown
  4. Type “products” as the Relationship Name
  5. Select public from the Reference Schema dropdown
  6. Select product as the Reference Table
  7. Select id from the From dropdown and maker_id from the To dropdown

Relationship Between User and Product Models in Hasura

Now if we visit the Hasura GraphQL Explorer, we’ll be able to query our products using GraphiQL.

Fetching Product Data Using GraphiQL in Hasura

Configuring Next.js to fetch GraphQL data with GraphQL Hooks

It’s time to configure our Next.js application to fetch data from our backend application. We’ll use GraphQL Hooks to fetch data from the backend application.

To install the dependencies, we need to SSH into our frontend container.

docker exec -it frontend_1 /bin/bash

Once inside the container, we can install the package using the following command.

bash-4.4# yarn add graphql-hooks graphql-hooks-memcache graphql-hooks-ssr

Since we’ll be using SSR, we have to create two utility files.

// frontend/lib/with-graphql-client.js

import React from "react";
import initGraphQL from "./init-graphql";
import Head from "next/head";
import { getInitialState } from "graphql-hooks-ssr";

export default App => {
  return class GraphQLHooks extends React.Component {
    static displayName = "GraphQLHooks(App)";
    static async getInitialProps(ctx) {
      const { AppTree } = ctx;
      let appProps = {};
      if (App.getInitialProps) {
        appProps = await App.getInitialProps(ctx);
      }
      // Run all GraphQL queries in the component tree
      // and extract the resulting data
      const graphQLClient = initGraphQL();

      let graphQLState = {};

      if (typeof window === "undefined") {
        try {
          // Run all GraphQL queries
          graphQLState = await getInitialState({
            App: <AppTree {...appProps} graphQLClient={graphQLClient} />,
            client: graphQLClient
          });
        } catch (error) {
          // Prevent GraphQL hooks client errors from crashing SSR.
          // Handle them in components via the state.error prop:
          // https://github.com/nearform/graphql-hooks#usequery
          console.error("Error while running `getInitialState`", error);
        }
        // getInitialState does not call componentWillUnmount
        // head side effect therefore need to be cleared manually
        Head.rewind();
      }

      return {
        ...appProps,
        graphQLState
      };
    }

    constructor(props) {
      super(props);
      this.graphQLClient = initGraphQL(props.graphQLState);
    }

    render() {
      return <App {...this.props} graphQLClient={this.graphQLClient} />;
    }
  };
};


// frontend/lib/init-graphql.js

import { GraphQLClient } from "graphql-hooks";
import memCache from "graphql-hooks-memcache";
import unfetch from "isomorphic-unfetch";

let graphQLClient = null;

function create(initialState = {}) {
  return new GraphQLClient({
    ssrMode: typeof window === "undefined",
    url: "http://localhost:8080/v1/graphql",
    cache: memCache({ initialState }),
    fetch: typeof window !== "undefined" ? fetch.bind() : unfetch, // eslint-disable-line
    headers: {
      "x-hasura-admin-secret": "secret"
    }
  });
}

export default function initGraphQL(initialState) {
  // Make sure to create a new client for every server-side request so that data
  // isn't shared between connections (which would be bad)
  if (typeof window === "undefined") {
    return create(initialState);
  }
  // Reuse client on the client-side
  if (!graphQLClient) {
    graphQLClient = create(initialState);
  }

  return graphQLClient;
}

These two files are present in the with-graphql-hooks example of Next.js.

We now need to update our _app.js file because we need to wrap our frontend application with the withGraphQLClient higher order function.

// frontend/pages/_app.js

import React from "react";
import NextApp from "next/app";
import { ThemeProvider, CSSReset, theme } from "@chakra-ui/core";
import { ClientContext } from "graphql-hooks";

import withGraphQLClient from "../lib/with-graphql-client";

class App extends NextApp {
  render() {
    const { Component, pageProps, graphQLClient } = this.props;
    return (
      <ThemeProvider theme={theme}>
        <CSSReset />
        <ClientContext.Provider value={graphQLClient}>
          <Component {...pageProps} />
        </ClientContext.Provider>
      </ThemeProvider>
    );
  }
}
export default withGraphQLClient(App);

Showing product data in the Next.js app

Now let’s update our ProductsList component to show actual products from our database.

// frontend/components/lists/products.js

import React from "react";
import { Text, Grid, Box, Spinner, Alert, AlertIcon } from "@chakra-ui/core";
import { useQuery } from "graphql-hooks";

export const allProductsQuery = `
  query {
    product {
      id
      name
      description
    }
  }
`;

const ProductsList = () => {
  const { loading, error, data } = useQuery(allProductsQuery, {
    skipCache: true
  });

  if (loading) {
    return (
      <Box
        w="100%"
        minH="100vh"
        d="flex"
        alignItems="center"
        justifyContent="center"
      >
        <Spinner />
      </Box>
    );
  }

  if (error) {
    return (
      <Box
        w="100%"
        minH="100vh"
        d="flex"
        alignItems="center"
        justifyContent="center"
      >
        <Alert status="error" variant="left-accent">
          <AlertIcon />
          There was an error processing your request
        </Alert>
      </Box>
    );
  }

  return (
    <Box
      w="100%"
      minH="100vh"
      p={4}
      d="flex"
      alignItems="center"
      justifyContent="center"
    >
      <Box w="800px" p={8} bg="gray.50" borderRadius="sm">
        <Grid
          templateColumns={[
            "repeat(1, 1fr)",
            "repeat(1, 1fr)",
            "repeat(2, 1fr)"
          ]}
          gap={8}
        >
          {data.product.map(item => {
            return (
              <Box
                key={item.id}
                w="100%"
                p={12}
                bg="#764ABC"
                rounded="md"
                color="white"
              >
                <Text fontSize="xl" fontWeight="semibold" lineHeight="short">
                  {item.name}
                </Text>
                <Text mt={2}>{item.description}</Text>
              </Box>
            );
          })}
        </Grid>
      </Box>
    </Box>
  );
};

export default ProductsList;

After making this change, if we visit http://localhost:3000/products, we should be able to see the actual products from our backend.

List of Products From Hasura

Extending the Next.js app to add new products

At this stage, we can build a form to add new products.

// frontend/components/forms/add-new-product.js

import React, { useState } from "react";
import { Box, Button, FormControl, FormLabel, Input } from "@chakra-ui/core";
import { parseCookies } from "nookies";
import { useMutation } from "graphql-hooks";
import { withRouter } from "next/router";

export const insertProductMutation = `
  mutation ($name: bpchar!, $description: bpchar!, $maker_id: uuid!) {
    insert_product(objects: {description: $description, name: $name, maker_id: $maker_id}) {
      returning {
        id
        name
      }
    }
  }
`;

const AddNewProductForm = ({ router }) => {
  const [insertProduct] = useMutation(insertProductMutation);
  const [name, setName] = useState("");
  const [description, setDescription] = useState("");
  const maker_id = parseCookies()["X-Hasura-User-Id"];

  const handleSubmit = async e => {
    e.preventDefault();

    await insertProduct({
      variables: {
        name,
        description,
        maker_id
      }
    });
    setName("");
    setDescription("");
    router.push("/products");
  };

  return (
    <Box
      w="100%"
      minH="100vh"
      p={4}
      d="flex"
      alignItems="center"
      justifyContent="center"
    >
      <Box w="500px" p={8} bg="gray.50" rounded="md">
        <FormControl mb={8}>
          <FormLabel htmlFor="name">Name</FormLabel>
          <Input
            type="text"
            id="name"
            aria-describedby="john"
            value={name}
            onChange={e => setName(e.target.value)}
          />
        </FormControl>
        <FormControl mb={8}>
          <FormLabel htmlFor="description">Description</FormLabel>
          <Input
            type="text"
            id="description"
            aria-describedby="Description"
            value={description}
            onChange={e => setDescription(e.target.value)}
          />
        </FormControl>
        <FormControl>
          <Button bg="#764ABC" color="white" onClick={handleSubmit}>
            Save
          </Button>
        </FormControl>
      </Box>
    </Box>
  );
};

export default withRouter(AddNewProductForm);

In the above form, we’re using the X-Hasura-User-Id cookie to send the id of the current user.

Now we just need to import this form in a page.

// frontend/pages/products/new.js

import React from "react";

import AddNewProductForm from "../../components/forms/add-new-product";

const CreateProductPage = () => {
  return <AddNewProductForm />;
};

export default CreateProductPage;

If we visit http://localhost:3000/products/new, we should now be able to see the new form.

Form to Add New Products

We can also add new products.

Adding New Products

In addition, we can view the new product in the ProductsList component.

Products List

Extending the Next.js app to edit products

What if we want users to be able to edit the products? We can create a form for that too.

// frontend/components/forms/edit-product.js

import React, { useState, useEffect } from "react";
import {
  Box,
  Button,
  FormControl,
  FormLabel,
  Input,
  Spinner
} from "@chakra-ui/core";
import { useManualQuery, useMutation } from "graphql-hooks";
import { withRouter } from "next/router";

export const singleProductQuery = `
  query ($id: uuid!){
    product_by_pk (id: $id) {
      id
      name
      description
      created_at
    }
  }
`;

export const updateProductMutation = `
  mutation ($name: bpchar!, $description: bpchar!, $productId: uuid!) {
    update_product(where: {id: {_eq: $productId}}, _set: {name: $name, description: $description}) {
      returning {
        id
        name
        description
      }
    }
  }
`;

const EditProductForm = ({ router }) => {
  const [name, setName] = useState("");
  const [description, setDescription] = useState("");
  const [fetchProduct, { loading, error }] = useManualQuery(singleProductQuery);
  const [updateProduct] = useMutation(updateProductMutation);

  useEffect(() => {
    const fetchProductThenSomething = async () => {
      const product = await fetchProduct({
        skipCache: true,
        variables: {
          id: router.query.productId
        }
      });
      setName(product.data.product_by_pk.name);
      setDescription(product.data.product_by_pk.description);
    };
    fetchProductThenSomething();
  }, []);

  if (loading) {
    return (
      <Box
        w="100%"
        minH="100vh"
        d="flex"
        alignItems="center"
        justifyContent="center"
      >
        <Spinner />
      </Box>
    );
  }

  if (error) {
    return (
      <Box
        w="100%"
        minH="100vh"
        d="flex"
        alignItems="center"
        justifyContent="center"
      >
        <Alert status="error" variant="left-accent">
          <AlertIcon />
          There was an error processing your request
        </Alert>
      </Box>
    );
  }

  const handleSubmit = async e => {
    e.preventDefault();
    await updateProduct({
      variables: {
        name,
        description,
        productId: router.query.productId
      }
    });
  };

  return (
    <Box
      w="100%"
      minH="100vh"
      p={4}
      d="flex"
      alignItems="center"
      justifyContent="center"
    >
      <Box w="500px" p={8} bg="gray.50" rounded="md">
        <FormControl mb={8}>
          <FormLabel htmlFor="name">Name</FormLabel>
          <Input
            type="text"
            id="name"
            aria-describedby="john"
            value={name}
            onChange={e => setName(e.target.value)}
          />
        </FormControl>
        <FormControl mb={8}>
          <FormLabel htmlFor="description">Description</FormLabel>
          <Input
            type="text"
            id="description"
            aria-describedby="Description"
            value={description}
            onChange={e => setDescription(e.target.value)}
          />
        </FormControl>
        <FormControl>
          <Button bg="#764ABC" color="white" onClick={handleSubmit}>
            Save
          </Button>
        </FormControl>
      </Box>
    </Box>
  );
};

export default withRouter(EditProductForm);

Let’s render that form in a separate page.

// frontend/pages/products/[productId]/edit.js

import React from "react";

import EditProductForm from "../../../components/forms/edit-product";

const EditProductPage = () => {
  return <EditProductForm />;
};

export default EditProductPage;

Now, if we visit any Product page (eg: http://localhost:3000/products/:productId/edit), we should be able to see the EditProductForm.

Form to Edit a Product

We can now update any product through this form.

Let’s say we want to make it so that makers can update only the products they created. To do that, we need to define custom permissions for the Product model.

Add Custom Permissions for Updating Products

We need to define a new Role named user. When the maker_id equals X-Hasura-User-Id, then and only then will the update be allowed.

We still need to make a few changes on our Next.js application to allow makers to edit the products that they created. We just need to update the SignInForm and SignUpForm components to store the X-Hasura-User-Role cookie.

// frontend/components/forms/sign-in.js

const handleSubmit = async e => {
    e.preventDefault();

    ...      

    setCookie({}, "X-Hasura-User-Id", data.id, {
        maxAge: 30 * 24 * 60 * 60,
        path: "/"
      });

      setCookie({}, "token", data.token, {
        maxAge: 30 * 24 * 60 * 60,
        path: "/"
      });

      setCookie({}, "X-Hasura-User-Role", data.roles[0], {
        maxAge: 30 * 24 * 60 * 60,
        path: "/"
      });
    }
  };

We also need to update the create function in the init-graphql.js file.

// frontend/lib/init-graphql.js

function create(initialState = {}) {
  return new GraphQLClient({
    ssrMode: typeof window === "undefined",
    url: "http://localhost:8080/v1/graphql",
    cache: memCache({ initialState }),
    fetch: typeof window !== "undefined" ? fetch.bind() : unfetch, // eslint-disable-line
    headers: {
      "x-hasura-admin-secret": "secret",
      "x-hasura-role": parseCookies()["X-Hasura-User-Role"],
      "X-Hasura-User-Id": parseCookies()["X-Hasura-User-Id"]
    }
  });
}

If we log in again, we should be able to see three cookies: X-Hasura-User-Id, X-Hasura-User-Role, and token.

Storing All Necessary Cookies in the Browser

If a user visits any product page, they will be able to update only the products they created.

Conclusion

It’s possible to use GraphQL Subscriptions with Hasura. You can also trigger webhooks on database events using Hasura.

You should now have a basic understanding of how to use Hasura with Next.js. The whole project is available on GitHub for your reference.

Note that the applications we built are not yet ready for production. See the Hasura documentation for more information about deploying Hasura applications. Likewise, you can read more about deploying Next.js applications in the Next.js documentation.


Plug: LogRocket, a DVR for web apps

 
LogRocket Dashboard Free Trial Banner
 
LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
 
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.
 
Try it for free.


The post Building a Product Hunt clone app using Hasura and Next.js appeared first on LogRocket Blog.

Posted on Feb 24 by:

bnevilleoneill profile

Brian Neville-O'Neill

@bnevilleoneill

Director content @LogRocket. I didn't write the post you just read. To find out who did, click the link directly above my name.

Discussion

markdown guide