Front-End Web & Mobile

Managing images in your NextJS app with AWS AppSync and the AWS CDK

In modern applications, sharing files is as much a necessity as having a database or authentication. When working with AWS, a common storage solution is to use Amazon Simple Storage Service (S3) where files are stored in an S3 bucket. The problem however, is a file often needs to be associated with data stored in a database. For example, a Product needs a price, name, and description, but also an image to display to customers.

Leveraging Amplify Libraries and the AWS Cloud Development Kit (CDK), in this post we walk through creating a fullstack application that not only enables frontend teams to upload and download files, but also use AWS AppSync as our API that brings in related data. This will enable backend teams to manage file permissions and infrastructure while frontend teams act as consumers.

To showcase this, we’ll work with two projects: A frontend built with the React framework NextJS and Amplify JavaScript libraries, and a backend consisting of an S3 bucket as well as several additional backend services.

The concepts in this post build off of previous fullstack guides: Secure AWS AppSync with Amazon Cognito using the AWS CDK.

By the end of this post, our backend will resemble the architecture diagram below while the code for this repository can be found here.

architecure diagram showing NextJS, Amazon Cognito, AWS AppSync, Amazon Cloudfront, Amazon S3, and Amazon DynamoDB

Considerations with creating an S3 Bucket

When it comes to items stored in an S3 bucket, there are 3 essential states to consider:

  1. Anonymous access: Users who view our files on a different domain than our application.
  2. Guest access: Users who view our files on our domain, but have not authenticated.
  3. Authenticated access: Users who have signed into our application.

Using an e-commerce application as an example, guest access would be provided when a customer visits our site. They should have the ability to read public files without having to authenticate. However, the store owner may be able to log into the site and create, read, update, and delete products based on availability.

In addition, when a customer decides to purchase items, they may be taken away from our site, to a payment processor like Stripe. Stripe will need to read our product images on their domain instead of our own. This is an example of anonymous access.

stripe-checkout

To accomplish these scenarios, we’ll tie our S3 bucket to an Amazon Cognito identity pool to make use of its authenticated and unauthenticated roles. Using this approach, all requests to access files in our bucket will have the ability to return a pre-signed URL.

In addition, for completely anonymous access, we’ll create an Amazon CloudFront distribution and enable public access to read from our bucket. This provides a public URL endpoint while offering the additional benefit of caching our public images.

Creating an S3 bucket

To create an S3 bucket using the CDK, we make use of the s3.Bucket L2 construct:

// lib/FileStorageStack.ts
const fileStorageBucket = new s3.Bucket(this, 's3-bucket', {
  removalPolicy: RemovalPolicy.DESTROY,
  autoDeleteObjects: true,
  cors: [
    {
      allowedMethods: [
        s3.HttpMethods.GET,
        s3.HttpMethods.POST,
        s3.HttpMethods.PUT,
        s3.HttpMethods.DELETE,
      ],
      allowedOrigins: props.allowedOrigins,
      allowedHeaders: ['*'],
    },
  ],
})

Note that the allowedOrigins field takes in an array of HTTP origins. This means when this stack is instantiated, we can specify which domains should have permission to use our bucket:

// bin/mainStack.ts
const app = new cdk.App()

const authStack = new AuthStack(app, 'AuthStack_Sample', {})

const identityStack = new IdentityStack(app, 'Identity_Sample', {})

const fileStorageStack = new FileStorageStack(app, 'FileStorageStack_Sample', {
  authenticatedRole: authStack.authenticatedRole,
  unauthenticatedRole: authStack.unauthenticatedRole,
  allowedOrigins: ['http://localhost:3000'],
})

Above, before the FileStorageStack is instantiated, we have an AuthStack and an IdentityStack

The AuthStack consists of an Amazon Cognito user pool while our IdentityStack contains our Cognito identity pool for the authenticated and unauthenticated roles. To view this setup, refer to the IdentityStack file of the repo.

As mentioned earlier, an identity pool can manage the permissions of two states: authenticated, and unauthenticated access. As such, we’ll attach various permissions to both states.

In our FileStorageStack.ts file, we can create a managed IAM policy statement and associate it with roles from our Cognito identity pool:

const canReadItemFromPublicDirectory = new iam.PolicyStatement({
    effect: iam.Effect.ALLOW,
    actions: ['s3:GetObject'],
    resources: [`arn:aws:s3:::${fileStorageBucket.bucketName}/public/*`],
})

const mangedPolicyForAmplifyUnauth = new iam.ManagedPolicy(
    this,
    'mangedPolicyForAmplifyUnauth',
    {
        description:
            'managed policy to allow usage of Storage Library for unauth. For folks that are on the site, but not signed in.',
        statements: [
            canReadItemFromPublicDirectory
        ],
        roles: [props.unauthenticatedRole],
    }
)

This says “Any unauthenticated user (guest user) can access a file from the public directory in my S3 bucket.

It’s common for unauthenticated users to be able to read items from S3 while only authenticated users can create, update, and delete.

To enable that scenario, we can attach a policy to the authenticatedRole given by our identity pool:

const canReadUpdateDeleteFromPublicDirectory = new iam.PolicyStatement({
  effect: iam.Effect.ALLOW,
  actions: ['s3:PutObject', 's3:GetObject', 's3:DeleteObject'],
  resources: [`arn:aws:s3:::${fileStorageBucket.bucketName}/public/*`],
})

const mangedPolicyForAmplifyAuth = new iam.ManagedPolicy(
    this,
    'mangedPolicyForAmplifyAuth',
    {
     statements: [
            canReadUpdateDeleteFromPublicDirectory,
     ],
     roles: [props.authenticatedRole],
    }
)

Although there are additional fine-grained options for us to provide, this is enough to deploy and verify in the frontend. For a more complete set of options and policy examples, refer to this project’s GitHub file.

Creating a CloudFront distribution for public S3 images

As mentioned earlier, Amazon CloudFront can be used for public bucket access and cached delivery. This is done by creating a separate endpoint that will allow us to access items in our S3 bucket.

https://myapp.cloudfront.amazonaws.com/public/my-product.png

However, in our application, we don’t want users to have the ability to access protected or otherwise private files by simply changing the URL:

https://myapp.cloudfront.amazonaws.com/private/secret-product.png

Fortunately, the L2 CDK construct for CloudFront allows us to specify an originPath.

const imagesCFDistribution = new Distribution(this, 'myDist', {
  defaultBehavior: {
    origin: new S3Origin(fileStorageBucket, { originPath: '/public' }),
    cachePolicy: CachePolicy.CACHING_OPTIMIZED,
    allowedMethods: AllowedMethods.ALLOW_GET_HEAD,
    cachedMethods: AllowedMethods.ALLOW_GET_HEAD,
    viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
  },
})

The value of this field is a directory in our S3 bucket. That directory name that will be appended to the base URL of each request. For example, if our bucket has a product at the location

/public/my-product.png

When the originPath is set to /public the image is accessible at:

https://myapp.cloudfront.amazonaws.com/my-product.png

Doing this ensures that there is never a way for a user to access files outside of the public directory.

To see how the CloudFront distribution fits inside of the fileStorageStack, refer to the related stack in the GitHub repo.

Updating our AppSync schema for S3 images

While there are plenty of situations where it makes sense to list only images, it’s more common that there is data associated with an image.

In our case, we’ll use AWS AppSync to store Product data in DynamoDB, but add an additional field that serves as a reference key to the image in our S3 bucket.

type Query {
	getProduct(id: ID!): Product @aws_iam @aws_cognito_user_pools
	listProducts: [Product] @aws_iam @aws_cognito_user_pools
}

type Mutation {
	createProduct(input: ProductInput!): Product @aws_cognito_user_pools
}

type Product @aws_iam @aws_cognito_user_pools {
	id: ID!
	name: String
	description: String
	price: Float
	isLive: Boolean
	imgKey: String
}

input ProductInput {
	id: ID
	name: String!
	description: String
	imgKey: String!
	price: Float!
	isLive: Boolean
}

Later in this post, we’ll see how the Amplify JavaScript libraries can be used alongside our GraphQL calls.

Deploying the backend

With our S3 bucket and CloudFront CDN created, and our GraphQL schema updated, we can deploy our application stacks by running the following command:

npx aws-cdk deploy –all –outputs-file ./outputs.json

By specifying  an outputs-file flag, all of our application’s CfnOutput logs will be stored as JSON in a newly created file in our repo.

Sample CDK Export

Creating a frontend with NextJS and AWS Amplify Libraries

The frontend repo contains all of the code needed to successfully view and manage products once the _app.tsx file has been populated with the values from the outputs.json file.

Once configured, view the application in your browser by running the following command from your project’s directory:

npm run dev

After the application loads in your browser, create a user by visiting the /admin page. This user will be allowed to perform CRUD operations on by using methods from the Storage module:

Example use cases for Amplify.Storage module

The Amplify JavaScript libraries work great in conjunction with the Amplify UI Component library. To create a form, Amplify Studio comes with a form builder UI that. This doesn’t require an AWS account, and once created, will download the UI components needed as a zip file that can be added to your project.

Form Builder UI Example

Once uploaded the product details should successfully display on the admin screen if marked as draft or the homepage if marked as a live product. This combination of database and storage is handled by combining the Storage method with the API methods:

Sending an image along with API request

Now, view the homepage in a separate browser to see the product displayed.

Note that public images on the homepage use our CloudFront CDN URL. This enables images to be cached and can be further optimized with NextJS.

Hosting the frontend

Amplify Hosting now supports NextJS 13 apps and significantly decreases build times compared to the previous runtime.

If wanting to setup a CDK stack that is configured to host your frontend, refer to the followup guide to this post.

Cleanup

To delete all of our backend resources, run the following command from the backend project directory:

npx aws-cdk destroy –all

Conclusion

In this post we learned how combining the AWS CDK with the Amplify JavaScript library can provide the flexibility needed for teams to scale independently and confidently, while still taking advantage of modern tooling. The backend application can be further extended with more S3 bucket permissions. In addition, the frontend application can be extended with features such as real-time subscriptions.

If wanting to see an example of an app that encompasses these features, refer to the updated real-time chat app project.