Front-End Web & Mobile

Build a Product Roadmap with Next.js and Amplify

In this post let’s imagine we are a car company with a public roadmap. We have a global audience, who are regularly checking to see what features we have delivered for our in-car entertainment system.

We’ll build an admin page for product managers to login and update the roadmap and have it reflected on the roadmap page.  The roadmap page has a large global audience and changes infrequently (only when a new feature is added) so it is a great candidate for Static Site Generation (SSG) with Incremental Static Regeneration (ISR) (when a new feature is released).

We’re building on the initial post, Deploy a Next.js 13 app with authentication to AWS Amplify, which initialized our project, implement AWS Cognito authentication and deployed our project to Amplify Hosting.

Deploy a GraphQL API

From the root of the project, we want to add a GraphQL API which will store our data by running the following command in a terminal:

amplify add api

And follow the prompts

? Select from one of the below mentioned services: (Use arrow keys)
❯ GraphQL
  REST
? Here is the GraphQL API that we will create. Select a setting to edit or continue (Use arrow keys)
  Name: testamplify
  Authorization modes: API key (default, expiration time: 7 days from now)
  Conflict detection (required for DataStore): Disabled
❯ Continue
? Choose a schema template:
  Single object with fields (e.g., “Todo” with ID, name, description)
  One-to-many relationship (e.g., “Blogs” with “Posts” and “Comments”)
❯ Blank Schema
? Do you want to edit the schema now? (Y/n) › Y

Replace the contents of schema.graphql with the following:


type Feature @model @auth(rules: [{ allow: owner }, { allow: public, operations: [read] }]) {
  id: ID!
  title: String!
  released: Boolean!
  description: String
  internalDoc: String
}

Finally, run the following command to deploy the API.

amplify push

Build an Admin screen for Product Managers

Now we need to create an administrative area for Product Managers to create, update and delete roadmap items.

Create the file pages/admin.js and add the following code to create an authenticated page with Cognito using the Amplify withAuthenticator higher-order component as we did in the initial post in this series.

// pages/admin.js
import {
  Button,
  Divider,
  Flex,
  Heading,
  View,
  withAuthenticator,
} from "@aws-amplify/ui-react";
import { Auth } from "aws-amplify";
import Link from "next/link";
import React, { useState } from "react";

function Admin() {
  // define state to be used later
  const [activeFeature, setActiveFeature] = useState(undefined);
  return (
    <View padding="2rem">
      <Flex justifyContent={"space-between"}>
        <Link href={"/admin"}>
          <Heading level={2}>AmpliCar Roadmap Admin</Heading>
        </Link>
        <Flex alignItems={"center"}>
          <Button type="button" onClick={() => Auth.signOut()}>
            Sign out
          </Button>
        </Flex>
      </Flex>
      <Divider marginTop={"medium"} marginBottom={"xxl"} />
      <Flex></Flex>
    </View>
  );
}

export default withAuthenticator(Admin);

Here we create a React component, Admin, for an administrative page. It uses the withAuthenticator higher-order component from the @aws-amplify/ui-react package to add authentication capabilities to the component.

The component also imports and uses the Button, Divider, Flex, Heading, and View components from the Amplify UI to render a top navigation bar with a sign out button, a divider, and some layout elements which will be used later.  When the sign out button is clicked, the Auth.signOut() method is called to sign the user out.

Finally state is added via activeFeature and setActiveFeature using React’s useState hook for managing state, which will be used later.

Visit http://localhost:3000/admin in your browser and you should see the following

Create an account and sign in and you should see the header with a Sign Out button.

We need to build a form to create and update features for our roadmap.  Create a components directory in the root of the project, add a component FeatureForm.js and add the following code to it:

// components/FeatureForm.js

import {
  Button,
  Flex,
  Heading,
  SwitchField,
  Text,
  TextField,
  View,
} from "@aws-amplify/ui-react";
import { API } from "aws-amplify";
import React, { useEffect, useState } from "react";
import { createFeature, updateFeature } from "../src/graphql/mutations";

function FeatureForm({ feature = null, setActiveFeature }) {
  const [id, setId] = useState(undefined);
  const [title, setTitle] = useState("");
  const [description, setDescription] = useState("");
  const [isReleased, setReleased] = useState(false);

  useEffect(() => {
    if (feature) {
      setId(feature.id);
      setTitle(feature.title);
      setDescription(feature.description);
      setReleased(feature.released);
    }
  }, [feature]);

  function resetFormFields() {
    setId(undefined);
    setTitle("");
    setDescription("");
    setReleased(false);
  }

  async function handleSaveFeature() {
    try {
      await API.graphql({
        authMode: "AMAZON_COGNITO_USER_POOLS",
        query: feature ? updateFeature : createFeature,
        variables: {
          input: {
            id: feature ? id : undefined,
            title,
            description,
            released: isReleased
          },
        },
      });

      feature && setActiveFeature(undefined);
      resetFormFields();
    } catch ({ errors }) {
      console.error(...errors);
      throw new Error(errors[0].message);
    }
  }

  return (
    <View>
      <Heading marginBottom="medium" level={5}>
        {feature ? "Edit" : "New"} Feature
      </Heading>
      <Flex direction={"column"} basis={"max-content"}>
        <TextField
          value={title}
          label="Title"
          errorMessage="There is an error"
          name="title"
          onChange={(e) => setTitle(e.target.value)}
        />

        <TextField
          value={description}
          name="description"
          label="Description"
          errorMessage="There is an error"
          onChange={(e) => setDescription(e.target.value)}
        />

        <SwitchField
          isChecked={isReleased}
          isDisabled={false}
          label="Released?"
          labelPosition="start"
          onChange={() => setReleased(!isReleased)}
        />

        <Flex marginTop="large">
          <Button
            onClick={() => {
              setActiveFeature(undefined);
              resetFormFields();
            }}
          >
            Cancel
          </Button>
          <Button onClick={() => handleSaveFeature()}>Save</Button>
        </Flex>
      </Flex>
    </View>
  );
}

export default FeatureForm;

This code defines a React component called FeatureForm that is used for creating and updating features in the application. The FeatureForm component receives a feature prop and a setActiveFeature prop, which are used to populate the form fields with data and to reset the form after a feature is saved. The component uses the useState hook to keep track of the state of the form fields, which includes the feature’s title, description, and whether it has been released.

When the component is rendered, it checks if a feature prop was passed in and if so, it uses the useEffect hook to populate the form fields with the data from that feature. Otherwise, the form fields are left empty.

The component contains several form fields for entering the feature’s title, description, and release status, as well as a save button and a cancel button. When the save button is clicked, the component issues a createFeature mutation via API.graphql  to save the feature to the GraphQL API. If the component was rendered with a feature prop, then it will update the existing feature in the database using the updateFeature mutation.

After the feature is saved, the component will reset the form fields and optionally reset the activeFeature state in the Admin component by calling the setActiveFeature callback prop.

Update the Admin component in pages/admin to add the FeatureForm component so that it is displayed on the roadmap admin and we’ll add a state variable to track if we are editing a feature, which will be used later as we display the features.

// pages/admin.js
import {
  Button,
  Divider,
  Flex,
  Heading,
  View,
  withAuthenticator,
} from "@aws-amplify/ui-react";
import { Auth } from "aws-amplify";
import Link from "next/link";
import React, { useState } from "react";
import FeatureForm from "../components/FeatureForm";

function Admin() {
  const [activeFeature, setActiveFeature] = useState(undefined);
  return (
    <View padding="2rem">
      // ...
      <Divider marginTop={"medium"} marginBottom={"xxl"} />
      <Flex>
        <FeatureForm feature={activeFeature} setActiveFeature**={setActiveFeature} />
      </Flex>
    </View>
  );
}

export default withAuthenticator(Admin);

Refresh the page and the New Feature form should be displayed.

Next, let’s create a table to display our features, allow us to edit and delete them.

Create a new component, FeaturesTable in the components directory and paste the following code:

// components/FeaturesTable.js
import {
  Button,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableRow,
  View,
} from "@aws-amplify/ui-react";
import { API, graphqlOperation } from "aws-amplify";
import React, { useEffect, useState } from "react";
import { deleteFeature } from "../src/graphql/mutations";
import { listFeatures } from "../src/graphql/queries";

function FeaturesTable({ initialFeatures = [], setActiveFeature }) {
  const [features, setFeatures] = useState(initialFeatures);
  
  useEffect(() => {
    const fetchFeatures = async () => {
      const result = await API.graphql(graphqlOperation(listFeatures));
      setFeatures(result.data.listFeatures.items);
    };

    fetchFeatures();
  }, []);

  async function handleDeleteFeature(id) {
    try {
      await API.graphql({
        authMode: "AMAZON_COGNITO_USER_POOLS",
        query: deleteFeature,
        variables: {
          input: {
            id,
          },
        },
      });
    } catch ({ errors }) {
      console.error(...errors);
    }
  }

  if (features.length === 0) {
    return <View>No features</View>;
  }

  return (
    <Table>
      <TableHead>
        <TableRow>
          <TableCell as="th">Feature</TableCell>
          <TableCell as="th">Released</TableCell>
          <TableCell></TableCell>
        </TableRow>
      </TableHead>
      <TableBody>
        {features.map((feature) => (
          <TableRow key={feature.id}>
            <TableCell>{feature.title}</TableCell>
            <TableCell>{feature.released ? "Yes" : "No"}</TableCell>
            <TableCell>
              <Button size="small" onClick={() => setActiveFeature(feature)}>
                Edit
              </Button>
              <Button
                size="small"
                onClick={() => handleDeleteFeature(feature.id)}
              >
                Delete
              </Button>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

export default FeaturesTable;

This code defines a React component called FeaturesTable that renders a table of features. The table displays each feature’s title and whether it has been released, and provides buttons for editing and deleting each feature.  The table is rendered using the Table, TableBody, TableCell, and TableRow components from Amplify UI.

The FeaturesTable component accepts an optional array of initialFeatures and a setActiveFeature function as props. It uses the useState hook to store the list of features in the features state variable, and the useEffect hook to fetch the list of features from our GraphQL API when the component mounts.

The handleDeleteFeature function is used to delete a feature by calling the deleteFeature GraphQL mutation. The handleDeleteFeature function is passed to the delete button for each feature in the table as a prop, so that when the button is clicked, the corresponding feature is deleted.

When the edit button is clicked, the setActiveFeature function is called with the clicked feature as an argument. This updates the activeFeature state variable in the Admin component, which causes the FeatureForm component to re-render with the new active feature. This allows the user to edit the selected feature using the form.

Next, we need to update pages/admin.js to include our FeaturesTable component.

// pages/admin.js
import {
  // ...
  withAuthenticator,
} from "@aws-amplify/ui-react";
import { Auth } from "aws-amplify";
import Link from "next/link";
import React, { useState } from "react";
import FeatureForm from "../components/FeatureForm";
import FeaturesTable from "../components/FeaturesTable";

function Admin() {
  const [activeFeature, setActiveFeature] = useState(undefined);
  return (
    <View padding="2rem">
      // ...
      <Divider marginTop={"medium"} marginBottom={"xxl"} />
      <Flex>
        <FeatureForm
          feature={activeFeature}
          setActiveFeature={setActiveFeature}
        />
        <FeaturesTable setActiveFeature={setActiveFeature} />
      </Flex>
    </View>
  );
}

export default withAuthenticator(Admin);

When we add this component and refresh we should see No features displayed since we have not added any features.

Enhancing the user experience with Server-Side Rendering (SSR)

To provide a great user experience, let’s update the admin page to fetch the features on the server using Server-Side Rendering (SSR). This experience is preferred over rendering the page without data, then waiting on a separate network request to complete before the data is populated.

// pages/admin.js
import {
  Button,
  Divider,
  Flex,
  Heading,
  View,
  withAuthenticator,
} from "@aws-amplify/ui-react";
import { Auth, withSSRContext } from "aws-amplify";
import Link from "next/link";
import React, { useState } from "react";
import FeatureForm from "../components/FeatureForm";
import FeaturesTable from "../components/FeaturesTable";
import { listFeatures } from "../src/graphql/queries";

export async function getServerSideProps({ req }) {
  const SSR = withSSRContext({ req });
  const response = await SSR.API.graphql({ query: listFeatures });

  return {
    props: {
      initialFeatures: response.data.listFeatures.items,
    },
  };
}

function Admin({ initialFeatures }) {
  const [activeFeature, setActiveFeature] = useState(undefined);
  return (
    <View padding="2rem">
      // ...
      <Divider marginTop={"medium"} marginBottom={"xxl"} />
      <Flex>
        <FeatureForm
          feature={activeFeature}
          setActiveFeature={setActiveFeature}
        />
        <FeaturesTable
          initialFeatures={initialFeatures}
          setActiveFeature={setActiveFeature}
        />
      </Flex>
    </View>
  );
}

export default withAuthenticator(Admin);

This code uses getServerSideProps to call the Amplify API.graphql with the listFeatures query and return the initial features in a props object which is injected into the React component on the server.  The Admin component receives the initialFeatures and passes them to the FeaturesTable component.

In the New Feature form, if we add a feature and refresh the page, it will show up in the table to the right.

Improving the customer experience with real-time updates

What we have built so far works, but does not offer the best user experience requiring product managers to refresh the page to see the latest features.

In the code below, we subscribe to data using Amplify subscriptions to implement this feature.

// components/FeaturesTable.js
import {
  Button,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableRow,
  View,
} from "@aws-amplify/ui-react";
import { API, graphqlOperation } from "aws-amplify";
import React, { useEffect, useState } from "react";
import { deleteFeature } from "../src/graphql/mutations";
import { listFeatures } from "../src/graphql/queries";
import { onCreateFeature, onDeleteFeature, onUpdateFeature, } from "../src/graphql/subscriptions";


function FeaturesTable({ initialFeatures = [], setActiveFeature }) {
  const [features, setFeatures] = useState(initialFeatures);

  useEffect(() => {
    const fetchFeatures = async () => {
      const result = await API.graphql(graphqlOperation(listFeatures));
      setFeatures(result.data.listFeatures.items);
    };

    fetchFeatures();
    
    const createSub = API.graphql(graphqlOperation(onCreateFeature)).subscribe({
      next: ({ value }) => {
        setFeatures((features) => [...features, value.data.onCreateFeature]);
      },
    });

    const updateSub = API.graphql(graphqlOperation(onUpdateFeature)).subscribe({
      next: ({ value }) => {
        setFeatures((features) => {
          const toUpdateIndex = features.findIndex(
            (item) => item.id === value.data.onUpdateFeature.id
          );
          if (toUpdateIndex === -1) {
            return [...features, value.data.onUpdateFeature];
          }
          return [
            ...features.slice(0, toUpdateIndex),
            value.data.onUpdateFeature,
            ...features.slice(toUpdateIndex + 1),
          ];
        });
      },
    });

    const deleteSub = API.graphql(graphqlOperation(onDeleteFeature)).subscribe({
      next: ({ value }) => {
        setFeatures((features) => {
          const toDeleteIndex = features.findIndex(
            (item) => item.id === value.data.onDeleteFeature.id
          );
          return [
            ...features.slice(0, toDeleteIndex),
            ...features.slice(toDeleteIndex + 1),
          ];
        });
      },
    });

    return () => {
      createSub.unsubscribe();
      updateSub.unsubscribe();
      deleteSub.unsubscribe();
    };
  }, []);

  // remainder of FeaturesTable component unmodified
}

export default FeaturesTable;

Here we add subscriptions createSub, updateSub and deleteSub to useEffect to listen for changes in data when pushed from AppSync against one of the subscription queries onCreateFeature, onUpdateFeature or onDeleteFeature.

We must implement logic to update our application via the next function in the object passed to the subscribe for each query.  createSub, appends new records to features by calling setFeatures and passing the records received via the subscription.  updateSub implements a callback to lookup the record modified and replace it in features with the version returned by the subscription.  deleteSub implements a callback to look up the record modified and remove it from features.  Finally, we return calls to the unsubscribe method on each subscription to clean them up.

When we create and edit items in the admin we see that the feature list is updated immediately.

In this post, we’ve built an admin interface for product managers to add features to a roadmap. In the next post, we’ll implement the customer facing page and add the ability to store an internal document related to the feature.