DEV Community

Cover image for How to build a customer-facing roadmap with React
Kartik Grewal for Canonic Inc.

Posted on

How to build a customer-facing roadmap with React

A product roadmap summarizes how a product strategy leads to the actual implementation and charts out your product's vision and direction.

This article will guide you through each step you need to build a roadmap tool using React for your SaaS startup.


Let's get started ๐Ÿš€

๐Ÿ“ฉ Step 1: Installing React
We'll start by creating a new react project using create-react-app.



npx create-react-app roadmap


Enter fullscreen mode Exit fullscreen mode

๐Ÿ“Step 2: Create a roadmap component

We'll create a component - Roadmap - that'll contain our display and API logic - src/components/Roadmap.

Create respective Roadmap.js, index.js and Roadmap.css files. Add following code in respective order.



import React from "react";

import "./Roadmap.css";

function Roadmap() {
  const columnMap = [
    {
      title: "\"Exploring\","
      tickets: [],
    },
    {
      title: "\"In Progress\","
      tickets: [],
    },
    {
      title: "\"Done\","
      tickets: [],
    },
    {
      title: "\"Leaving it for now\","
      tickets: [],
    },
  ];

  return (
    <div className="roadmap">
      {columnMap.map((column, i) => (
        <div className="roadmap-column" key={`${column.title}-${i}`}>
          <div className="roadmap-column-heading">{column.title}</div>
          <div className="roadmap-cards">
            {column.tickets.map((t, i) => (
              <div key={`exploring-${i}`} className="roadmap-cards-item">
                <div className="roadmap-cards-item-heading">{t.title}</div>
                <div
                  className="roadmap-cards-item-content"
                  dangerouslySetInnerHTML={{ __html: t.description }}
                />
                <svg
                  xmlns="http://www.w3.org/2000/svg"
                  x="0px"
                  y="0px"
                  viewBox="0 0 100 125"
                  style={{
                    enableBackground: "new 0 0 100 100",
                    maxWidth: "18px",
                    cursor: "pointer",
                  }}
                >
                  <g>
                    <g>
                      <path d="M81.7,40.4H64.6l3.6-17.9c0.5-2.5,0.1-5-1.2-7.1l0,0c-1.5-2.5-4.9-3-7-1.1L29.8,38.7c-1.2,1.1-1.8,2.6-1.8,4.1v33.1    c0,2.4,1.5,4.5,3.7,5.3l12.9,3.4c3.1,1.1,6.4,1.6,9.7,1.6h18c6.6,0,12.5-4.2,14.7-10.5l6.9-20c0.3-0.9,0.4-1.9,0.4-2.9v0    C94.3,46,88.7,40.4,81.7,40.4z" />
                      <path d="M22.2,40.4H7.8c-1.5,0-2.8,1.2-2.8,2.8v34c0,1.5,1.2,2.7,2.7,2.8h14.5c1.5,0,2.8-1.2,2.8-2.8v-34    C25,41.6,23.7,40.4,22.2,40.4z" />
                    </g>
                  </g>
                </svg>
              </div>
            ))}
          </div>
        </div>
      ))}
    </div>
  );
}

export default Roadmap;


Enter fullscreen mode Exit fullscreen mode


export { default } from "./Roadmap";


Enter fullscreen mode Exit fullscreen mode


.roadmap {
  display: flex;
  width: 100%;
  max-width: 900px;
  min-height: 400px;
}

.roadmap > div:not(:last-child) {
  margin-right: 16px;
}

.roadmap-column {
  display: flex;
  flex-direction: column;
  flex: 1;
  background: #eef0fc;
  border-radius: 4px;
  padding: 6px 10px;
}

.roadmap-column-heading {
  color: #4d5273;
  font-size: 14px;
  font-weight: 500;
  padding: 10px;
  margin-bottom: 10px;
  text-align: center;
}

.roadmap-cards {
  display: flex;
  flex-direction: column;
}

.roadmap-cards > div:not(:last-child) {
  margin-bottom: 12px;
}

.roadmap-cards-item {
  background: #fff;
  border-radius: 4px;
  padding: 12px;
  box-shadow: 0 1px 0 #091e4240;
}
.roadmap-cards-item-heading {
  font-size: 14px;
  font-weight: 500;
}

.roadmap-cards-item-upvote {
  display: flex;
  font-size: 12px;
  line-height: 18px;
}

.roadmap-cards-item-upvote-count {
  margin-left: 6px;
}

.roadmap-cards-item p {
  font-size: 12px;
  margin-top: 7px;
  line-height: 1.5;
}

svg.not-filled path {
  fill: #fff;
  stroke: #000;
  stroke-width: 8px;
}

@media (max-width: 600px) {
  .roadmap {
    flex-direction: column;
  }

  .roadmap > div:not(:last-child) {
    margin-right: 0;
    margin-bottom: 26px;
  }
}


Enter fullscreen mode Exit fullscreen mode

Now that our display component is created, let's app it to App.js and see how our component looks.

๐Ÿ‘จโ€๐Ÿ”งStep 3: Add component to App
We import our component in App.js and remove all the unnecessary files and code. Our code - App.js, App.css and component should look like below.

Add following code to App.js and App.css respectively.



import React from "react";

import Roadmap from "./components/Roadmap";
import "./App.css";

function App() {
  return (
    <div>
      <div className="wrapper-heading">Roadmap</div>
      <Roadmap />
    </div>
  );
}

export default App;


Enter fullscreen mode Exit fullscreen mode


@import url("https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap");

html,
body {
  font-family: "Roboto", sans-serif;
}

.wrapper-heading {
  padding: 16px;
  text-align: center;
  margin-bottom: 18px;
  max-width: 900px;
  color: #4d5273;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 1.6px;
}


Enter fullscreen mode Exit fullscreen mode

Now run yarn start in root of our project and our component should look like this.

Roadmap

Now it's time to integrate this with a backend to fetch our roadmap tickets and display them. ๐Ÿ‘ฉโ€๐Ÿ”ง

๐Ÿ‘ฉโ€๐Ÿ’ปStep 4: Get your APIs

Follow the below-stated pointers and get your APIs!

  • Cloning the sample project - To get the APIs, you can visit this link and click on the top right button to clone the project.

cloning

  • Deploy and get the backend URL - Having cloned the project, it's now time to deploy it so that we can get our APIs and backend hosted. Open the cloned project if you are not already there and youโ€™ll see a Deploy button on the top right.

    Click Deploy โ†’ Select an environment โ†’ Hit deploy.

    Upon completing the project, it will provide an API URL.

deploy

๐Ÿ“Checking the documentation
Before we get started with backend integration, let's move to the documentation by clicking on docs on the left sidebar so that we get a better understanding of the APIs involved.

documentation


Backend integration with GraphQL ๐Ÿ‘‡

Once you have your APIs ready, we can start by installing graphql.

๐Ÿ“ฉStep 5: Install GraphQL packages
We will need two packages for this step since we'll be using graphql to pull our data from the backend - Apollo Client and GraphQL.



yarn add @apollo/client graphql


Enter fullscreen mode Exit fullscreen mode

โš’Step 6: Configure graphql

Configure the Apollo Client in the project directory, inside App.js so it would communicate with the backend.

Be sure to replace the uri with the one you'll get from Canonic.



import React from "react";
import { ApolloProvider, InMemoryCache, ApolloClient } from "@apollo/client";

import Roadmap from "./components/Roadmap";

import "./App.css";

/**
 * Connecting with the backend using apollo client
 */
const client = new ApolloClient({
  // make sure you update the URI here to point to your backend
  uri: "https://roadmap-35a418.can.canonic.dev/graphql",
  cache: new InMemoryCache(),
});

function App() {
  return (
    <ApolloProvider client={client}>
      <div>
        <div className="wrapper-heading">Roadmap</div>
        <Roadmap />
      </div>
    </ApolloProvider>
  );
}

export default App;


Enter fullscreen mode Exit fullscreen mode

๐Ÿ‘จโ€๐Ÿ”งStep 7: Querying the data

We store our graphql queries inside a directory src/gql. Inside which we create a file and name it queries.js.

This is where we will write the graphql queries for querying the data.



import { gql } from "@apollo/client";

/**
 * gql query to get roadmap
 * The query parameters we got straight from Canonic autogenerated documentation
 */
export const GET_ROADMAP = gql`
  query {
    roadmaps {
      _id
      title
      description
      upvotes {
        count
      }
      stage {
        value
      }
    }
  }
`;


Enter fullscreen mode Exit fullscreen mode

The next step is Querying data and displaying our roadmap tickets. We'll execute the graphql query in the Roadmap component and display the tickets fetched according to their status.

We modify Roadmap.js to achieve the above mentioned logic and get the tickets.



import React from "react";
import { useQuery } from "@apollo/client";

import { GET_ROADMAP } from "../../gql/queries";

import "./Roadmap.css";

function Roadmap() {
  const { data = {}, loading } = useQuery(GET_ROADMAP);

  const { roadmaps = [] } = data;

  // dividing tickets into their respective categories
  const exploringTickets = roadmaps.filter(
    (t) => t.stage.value === "EXPLORING"
  );
  const inProgressTickets = roadmaps.filter(
    (t) => t.stage.value === "IN_PROGRESS"
  );
  const doneTickets = roadmaps.filter((t) => t.stage.value === "DONE");
  const leavingItForNowTickets = roadmaps.filter(
    (t) => t.stage.value === "LEAVING_IT_FOR_NOW"
  );

  const columnMap = [
    {
      title: "Exploring",
      tickets: exploringTickets,
    },
    {
      title: "In Progress",
      tickets: inProgressTickets,
    },
    {
      title: "Done",
      tickets: doneTickets,
    },
    {
      title: "Leaving it for now",
      tickets: leavingItForNowTickets,
    },
  ];

  return (
    <div className="roadmap">
      {loading ? (
        <svg
          xmlns="http://www.w3.org/2000/svg"
          style={{
            margin: "auto",
            background: "none",
            display: "block",
            shapeRendering: "auto",
            maxWidth: "30px",
            marginTop: "-20px",
          }}
          width="200px"
          height="200px"
          viewBox="0 0 100 100"
          preserveAspectRatio="xMidYMid"
        >
          <circle
            cx="50"
            cy="50"
            fill="none"
            stroke="#4d5273"
            strokeWidth="10"
            r="35"
            strokeDasharray="164.93361431346415 56.97787143782138"
          >
            <animateTransform
              attributeName="transform"
              type="rotate"
              repeatCount="indefinite"
              dur="1s"
              values="0 50 50;360 50 50"
              keyTimes="0;1"
            ></animateTransform>
          </circle>
        </svg>
      ) : (
        <>
          {columnMap.map((column, i) => (
            <div className="roadmap-column" key={`${column.title}-${i}`}>
              <div className="roadmap-column-heading">{column.title}</div>
              <div className="roadmap-cards">
                {column.tickets.map((t, i) => (
                  <div key={`exploring-${i}`} className="roadmap-cards-item">
                    <div className="roadmap-cards-item-heading">{t.title}</div>
                    <div
                      className="roadmap-cards-item-content"
                      dangerouslySetInnerHTML={{ __html: t.description }}
                    />
                    <svg
                      xmlns="http://www.w3.org/2000/svg"
                      x="0px"
                      y="0px"
                      viewBox="0 0 100 125"
                      style={{
                        enableBackground: "new 0 0 100 100",
                        maxWidth: "18px",
                        cursor: "pointer",
                      }}
                    >
                      <g>
                        <g>
                          <path d="M81.7,40.4H64.6l3.6-17.9c0.5-2.5,0.1-5-1.2-7.1l0,0c-1.5-2.5-4.9-3-7-1.1L29.8,38.7c-1.2,1.1-1.8,2.6-1.8,4.1v33.1    c0,2.4,1.5,4.5,3.7,5.3l12.9,3.4c3.1,1.1,6.4,1.6,9.7,1.6h18c6.6,0,12.5-4.2,14.7-10.5l6.9-20c0.3-0.9,0.4-1.9,0.4-2.9v0    C94.3,46,88.7,40.4,81.7,40.4z" />
                          <path d="M22.2,40.4H7.8c-1.5,0-2.8,1.2-2.8,2.8v34c0,1.5,1.2,2.7,2.7,2.8h14.5c1.5,0,2.8-1.2,2.8-2.8v-34    C25,41.6,23.7,40.4,22.2,40.4z" />
                        </g>
                      </g>
                    </svg>
                  </div>
                ))}
              </div>
            </div>
          ))}
        </>
      )}
    </div>
  );
}

export default Roadmap;


Enter fullscreen mode Exit fullscreen mode

Image description


๐Ÿ™ŒBONUS: Adding upvote to Roadmap tickets

As a bonus, let's add a feature for the users to upvote the tickets. By doing so, we can gain a deeper understanding of what our users are thinking and what they expect from us. Find out more about the benefits of having a public roadmap here.

Step 8: Mutating Data

We create a new file to store our mutations in src/gql/mutations.js. Taking a reference from auto-generated documentation, we can specify the mutation parameters.



import { gql } from "@apollo/client";

/**
 * gql query to get roadmap
 * The query parameters we got straight from Canonic autogenerated documentation
 */
export const UPVOTE = gql`
  mutation Upvote($ticketId: ID!) {
    createUpvote(input: { ticket: $ticketId }) {
      _id
    }
  }
`;


Enter fullscreen mode Exit fullscreen mode

Step 9: Add upvote logic in the Component

We can now connect our mutation in Roadmap component. In this case, we will save the upvote not only on the backend but also in the local storage since we will not be creating a user on the backend and we want to retain the upvote information.

We make the following changes in Roadmap.js and add the mutation logic. Our final file looks like below.



import React from "react";
import { useQuery, useMutation } from "@apollo/client";

import { GET_ROADMAP } from "../../gql/queries";
import { UPVOTE } from "../../gql/mutations";

import "./Roadmap.css";

function Roadmap() {
  const { data = {}, loading } = useQuery(GET_ROADMAP);
  const [upvoteTicket] = useMutation(UPVOTE, {
    context: {
      headers: {
        Authorization:
          "617bdcfc530d0d0009c04985-c2ca6caf-485c-4bc1-8ac8-4b9defe2707e",
      },
    },
  });

  const { roadmaps = [] } = data;

  const [upvotes, setUpvotes] = React.useState([]);

  // dividing tickets into their respective categories
  const exploringTickets = roadmaps.filter(
    (t) => t.stage.value === "EXPLORING"
  );
  const inProgressTickets = roadmaps.filter(
    (t) => t.stage.value === "IN_PROGRESS"
  );
  const doneTickets = roadmaps.filter((t) => t.stage.value === "DONE");
  const leavingItForNowTickets = roadmaps.filter(
    (t) => t.stage.value === "LEAVING_IT_FOR_NOW"
  );

  const columnMap = [
    {
      title: "Exploring",
      tickets: exploringTickets,
    },
    {
      title: "In Progress",
      tickets: inProgressTickets,
    },
    {
      title: "Done",
      tickets: doneTickets,
    },
    {
      title: "Leaving it for now",
      tickets: leavingItForNowTickets,
    },
  ];

  const handleUpvoteTicket = React.useCallback(
    async (id) => {
      setUpvotes((upvotes) => [...upvotes, id]);
      localStorage.setItem(`${id}`, true);

      upvoteTicket({ variables: { ticketId: id } });
    },
    [upvoteTicket]
  );

  const isTicketUpvoted = React.useCallback(
    (id) => !!upvotes.find((t) => t === id) || localStorage.getItem(id),
    [upvotes]
  );

  return (
    <div className="roadmap">
      {loading ? (
        <svg
          xmlns="http://www.w3.org/2000/svg"
          style={{
            margin: "auto",
            background: "none",
            display: "block",
            shapeRendering: "auto",
            maxWidth: "30px",
            marginTop: "-20px",
          }}
          width="200px"
          height="200px"
          viewBox="0 0 100 100"
          preserveAspectRatio="xMidYMid"
        >
          <circle
            cx="50"
            cy="50"
            fill="none"
            stroke="#4d5273"
            strokeWidth="10"
            r="35"
            strokeDasharray="164.93361431346415 56.97787143782138"
          >
            <animateTransform
              attributeName="transform"
              type="rotate"
              repeatCount="indefinite"
              dur="1s"
              values="0 50 50;360 50 50"
              keyTimes="0;1"
            ></animateTransform>
          </circle>
        </svg>
      ) : (
        <>
          {columnMap.map((column, i) => (
            <div className="roadmap-column" key={`${column.title}-${i}`}>
              <div className="roadmap-column-heading">{column.title}</div>
              <div className="roadmap-cards">
                {column.tickets.map((t, i) => (
                  <div key={`exploring-${i}`} className="roadmap-cards-item">
                    <div className="roadmap-cards-item-heading">{t.title}</div>
                    <div
                      className="roadmap-cards-item-content"
                      dangerouslySetInnerHTML={{ __html: t.description }}
                    />
                    <svg
                      xmlns="http://www.w3.org/2000/svg"
                      x="0px"
                      y="0px"
                      viewBox="0 0 100 125"
                      style={{
                        enableBackground: "new 0 0 100 100",
                        maxWidth: "18px",
                        cursor: "pointer",
                      }}
                      className={!isTicketUpvoted(t._id) && "not-filled"}
                      onClick={() => handleUpvoteTicket(t._id)}
                    >
                      <g>
                        <g>
                          <path d="M81.7,40.4H64.6l3.6-17.9c0.5-2.5,0.1-5-1.2-7.1l0,0c-1.5-2.5-4.9-3-7-1.1L29.8,38.7c-1.2,1.1-1.8,2.6-1.8,4.1v33.1    c0,2.4,1.5,4.5,3.7,5.3l12.9,3.4c3.1,1.1,6.4,1.6,9.7,1.6h18c6.6,0,12.5-4.2,14.7-10.5l6.9-20c0.3-0.9,0.4-1.9,0.4-2.9v0    C94.3,46,88.7,40.4,81.7,40.4z" />
                          <path d="M22.2,40.4H7.8c-1.5,0-2.8,1.2-2.8,2.8v34c0,1.5,1.2,2.7,2.7,2.8h14.5c1.5,0,2.8-1.2,2.8-2.8v-34    C25,41.6,23.7,40.4,22.2,40.4z" />
                        </g>
                      </g>
                    </svg>
                  </div>
                ))}
              </div>
            </div>
          ))}
        </>
      )}
    </div>
  );
}

export default Roadmap;


Enter fullscreen mode Exit fullscreen mode

Note be sure to create access_tokens for your canonic APIs for mutating the data. Read more about it here

And that's it, you've built the roadmap! ๐ŸŽ‰

Image description

Conclusion:

Roadmaps for your products offer several advantages, including a better understanding of the strategy and vision, guidance for executing the strategy, facilitation of discussion and opinions, etc.

Follow this step to step guide and build Roadmaps for your own SaaS startups.

Check out the live demo link here

and you can checkout the sample code here

You can also check out our other guidesย here.

Join us on discord to discuss or share with our community. Write to us for any support requests atย support@canonic.dev. Check out ourย websiteย to know more about Canonic. Let us know in the comments below what you think about the guide. Thank you!

Top comments (0)