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.