Front-End Web & Mobile

Add storage to a Next.js 13 app with AWS Amplify

This post builds on the initial post, Deploy a Next.js 13 app with authentication to AWS Amplify, which initialized our project with AWS Cognito authentication and deployed our project to Amplify Hosting and the previous post, Build a Product Roadmap with Next.js and Amplify, where we built an admin page for product managers to login and update the roadmap.

Display the Roadmap to Customers

Our requirements state that the customer facing roadmap be performant and up-to-date within an hour of publishing a new feature to our roadmap. The Incremental Static Regeneration (ISR) feature from Next.js allows you to create or update static pages after you’ve built your site.

Update pages/index.js with the following code to show customers the features entered via the roadmap admin.

import { Card, Collection, Heading, View } from "@aws-amplify/ui-react";
import { API, graphqlOperation } from "aws-amplify";
import React from "react";
import { listFeatures } from "../src/graphql/queries";

export async function getStaticProps() {
  const response = await API.graphql(
    graphqlOperation(listFeatures, {
      filter: { released: { eq: true } },
    })
  );

  return {
    props: {
      features: response.data.listFeatures.items,
    },
    revalidate: 3600 // revalidate every hour
  };
}

export default function Home({ features = [] }) {
  return (
    <View padding="2rem">
      <Heading level={2}>AmpliCar Roadmap Delivered Features</Heading>
      <View as="main" padding="2rem">
        <Collection items={features} type="list" gap="5px" wrap="nowrap">
          {(feature, index) => (
            <Card key={index} maxWidth="50rem">
              <Heading>{feature.title}</Heading>
            </Card>
          )}
        </Collection>
      </View>
    </View>
  );
}

Per our requirements, this page is implemented as ISR and using a filter retrieves the released features only in getStaticProps, then displays them in a list. During the build phase a static page is generated. The revalidate prop, currently set to 1 hour, sets a cache for the page based on its value. A visitor to the page after the hour will cause the page to be regenerated and cached, and will show the latest updates made by the product managers.

Visit http://localhost:3000 to view the page.

AmpliCar Roadmap Page

Attach internal documents to roadmap items

A final requirement is to allow product managers to save an internal document with a roadmap item.

Using the Amplify CLI, we need to enable Storage for the documents.

From the root of the project run:

amplify add storage

And follow the prompts

? Select from one of the below mentioned services: (Use arrow keys)
❯ Content (Images, audio, video, etc.)
  NoSQL Database
✔ Provide a friendly name for your resource that will be used to label this category in the project: · ampliCarDocs
✔ Provide bucket name: · 202301271600amplifyaeca8e896e42447b9a4842d9d7aa
✔ Who should have access: · Auth users only
✔ What kind of access do you want for Authenticated users? · create/update, read, delete
✔ Do you want to add a Lambda Trigger for your S3 Bucket? (y/N) · no

Finally run amplify push to deploy the Storage resources.

Next, we need to update our FeatureForm in the AmpliCar Admin page to allow for a file to be uploaded.

// component/FeatureForm.js
import {
  Button,
  Flex,
  Heading,
  SwitchField,
  Text,
  TextField,
  View,
} from "@aws-amplify/ui-react";
import { API, Storage } 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);
  const [internalDoc, setInternalDoc] = useState("");

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

  async function handleUploadDoc(e) {
    const file = e.target.files[0];
    const fileName = `${Date.now()}-${file.name}`;
    try {
      await Storage.put(fileName, file, {
        contentType: file.type,
      });
      setInternalDoc(fileName);
    } catch (error) {
      console.log("Error uploading file: ", error);
    }
  }

  async function handleRemoveDoc() {
    try {
      await Storage.remove(internalDoc);
      setInternalDoc("");
    } catch (error) {
      console.log("Error removing file: ", error);
    }
  }

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

  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,
            internalDoc: internalDoc,
          },
        },
      });

      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)}
        />

        {feature && internalDoc ? (
          <div>
            <Text>Attachment:</Text>
            <Text fontWeight={"bold"}>
              {internalDoc}{" "}
              <Button size="small" onClick={handleRemoveDoc}>
                X
              </Button>
            </Text>
          </div>
        ) : (
          <div>
            <Text>Upload a file:</Text>
            <input type="file" onChange={handleUploadDoc} />
          </div>
        )}

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

export default FeatureForm;

First we import Storage from aws-amplify. Next, we need to track the state of our internal document via the internalDoc state variable, which we add to our useEffect, resetFormFields and handleSaveFeature.

handleUploadDoc will upload our document using the Storage library and handleRemoveDoc will remove the document if the user clicks on the button to delete it. Finally, we display a file input or list the filename if a product manager has attached a document.

Next, we need to update our FeaturesTable component to provide a way for other product managers to download the file and delete the file in storage at the same time it is deleting the feature from the API.

// components/FeaturesTable.js
import {
  Button,
  Flex,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableRow,
  View,
} from "@aws-amplify/ui-react";
import { API, graphqlOperation, Storage } 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();
    };
  }, []);

  function downloadBlob(blob, filename) {
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = filename || "download";
    const clickHandler = () => {
      setTimeout(() => {
        URL.revokeObjectURL(url);
        a.removeEventListener("click", clickHandler);
      }, 150);
    };
    a.addEventListener("click", clickHandler, false);
    a.click();
    return a;
  }

  async function handleDownload(fileKey) {
    const result = await Storage.get(fileKey, { download: true });
    downloadBlob(result.Body, fileKey);
  }

  async function onDeleteInternalDoc(internalDoc) {
    try {
      await Storage.remove(internalDoc);
    } catch ({ errors }) {
      console.error(...errors);
    }
  }

  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>
              <Flex>
                <Button size="small" onClick={() => setActiveFeature(feature)}>
                  Edit
                </Button>
                <Button
                  size="small"
                  onClick={async () =>
                    await Promise.all([
                      // delete the document via Storage
                      onDeleteInternalDoc(feature.internalDoc),
                      handleDeleteFeature(feature.id),
                    ])
                  }
                >
                  Delete
                </Button>
                {feature.internalDoc ? (
                  <Button
                    size="small"
                    onClick={() => handleDownload(feature.internalDoc)}
                  >
                    Download File
                  </Button>
                ) : undefined}
              </Flex>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

export default FeaturesTable;

First, we import Storage from aws-amplify. Next, we construct handleDownload which retrieves the file from Storage and makes it downloadable using the downloadBlob function which uses the Blob API to programmatically download Blobs using JavaScript. The onDeleteInternalDoc function is constructed to delete the file using the Storage API.

Then, we update the Delete button to implement a Promise.all calling both onDeleteInternalDoc and handleDeleteFeature.

Finally, we add our Download File button which calls handleDownload when clicked.

With this our application is complete!

AmpliCar Admin Final

What we’ve built

Referring to our requirements, we wanted an application that allowed product managers to create and maintain a public roadmap for customers. For the public roadmap page, we added ISR to update the page at an interval appropriate to our use case and benefit from the efficiency of caching. The roadmap admin needed to feel like an application, updating the UI with every edit or delete, so subscriptions were used to offer real-time feedback to product managers.  Finally, we added the capabilities to attach an internal document to a feature.