【NextJS】Upload and Render Images with AWS S3

1. 概要

前回はCloud Firestoreのリアルタイムアップデート(onSnapshot)を使い、シンプルなチャットアプリを作る内容についてでした。今回はAWS S3に画像をアップロードしたり画面に表示する内容になります。

対象としては開発を1年程やってて自分で最初から開発してみたい方になります。そのため細かい用語などの説明はしません。

2. nodeのインストール

こちらを参考

3. プロジェクトを作成

3-1-1. こちらを参考

3-2-1. S3 Bucketを作成

3-3-1. AWS IAM ユーザーを作成

  • ポリシー
    • アクセス許可を指定
      • s3:DeleteObject
      • s3:GetObject
      • s3:ListBucket
      • s3:PutObject
      • s3:PutObjectAcl

3-4-1. CORSの設定

※Cross-Origin Resource Sharing(CORS)

[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "GET"
        ],
        "AllowedOrigins": [
            "http://localhost:3000"
        ],
        "ExposeHeaders": [
            "Access-Control-Allow-Origin"
        ]
    }
]
  • 「localhost」からアクセスできるように設定

4. 必要なライブラリをインストール

4-1-1. こちらを参考

npm i @aws-sdk/client-s3
npm i @aws-sdk/s3-presigned-post
npm i @aws-sdk/s3-request-presigner

5. ソースコード

※前回より差分のみを記載

5-1-1. .env.local

NEXT_PUBLIC_AWS_REGION=******
NEXT_PUBLIC_AWS_ACCESS_KEY_ID=******
NEXT_PUBLIC_AWS_SECRET_ACCESS_KEY=******
NEXT_PUBLIC_AWS_S3_BUCKET_NAME=******

5-1-2. next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "images.dog.ceo",
        port: "",
        pathname: "/breeds/**",
      },
      {
        protocol: "https",
        hostname: "s3.ap-northeast-1.amazonaws.com",
        port: "",
        pathname: "/YOUR-BUCKET-NAME/**",
      },
    ],
  },
};

module.exports = nextConfig;

5-1-3. src/app/nextjs/nextjs10/s3-client.ts

import { S3Client } from "@aws-sdk/client-s3";

const s3Client = new S3Client({
  region: process.env.NEXT_PUBLIC_AWS_REGION,
  credentials: {
    accessKeyId: process.env.NEXT_PUBLIC_AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.NEXT_PUBLIC_AWS_SECRET_ACCESS_KEY!,
  },
});

export default s3Client;

5-1-4. src/app/nextjs/nextjs10/handle-s3.ts

"use server";

import {
  _Object,
  GetObjectCommand,
  paginateListObjectsV2,
  ListObjectsV2CommandOutput,
} from "@aws-sdk/client-s3";
import s3Client from "./s3-client";
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
import { NextResponse } from "next/server";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { Paginator } from "@smithy/types";

export type Props = {
  keyName: string;
  image?: FormDataEntryValue;
};

const bucketName: string = process.env.NEXT_PUBLIC_AWS_S3_BUCKET_NAME!;

const uploadFile = async (props: Props) => {
  const contentType: string = "image/";
  try {
    const { url, fields } = await createPresignedPost(s3Client, {
      Bucket: bucketName,
      Key: props.keyName,
      Conditions: [
        ["content-length-range", 0, 1000000], // content length restrictions: 0-1MB
        ["starts-with", "$Content-Type", contentType], // content type restriction
      ],
      Fields: {
        "Content-Type": contentType,
        acl: "bucket-owner-full-control",
      },
      Expires: 600, //Seconds before the presigned post expires. 3600 by default.
    });
    const fd = new FormData();
    Object.entries(fields).forEach(([key, value]) =>
      fd.append(key, value as string)
    );
    fd.append("file", props.image!);
    const res: Response = await fetch(url, { method: "POST", body: fd });
    if (!res.ok) {
      return NextResponse.json({ status: res.status });
    }
    return NextResponse.json({ status: res.ok, url, fields });
  } catch (e) {
    throw e;
  }
};

const createPresignedUrlWithClient = async (command: GetObjectCommand) => {
  return await getSignedUrl(s3Client, command, { expiresIn: 3600 });
};

const getFile = async (props: Props) => {
  const key: string = props.keyName;
  const command = new GetObjectCommand({
    Bucket: bucketName,
    Key: key,
  });
  const presigned: string = await createPresignedUrlWithClient(command);
  return presigned;
};

const listFilesInBucket = async () => {
  const pageSize: string = "10";
  const objects: string[] = [];
  try {
    const paginator: Paginator<ListObjectsV2CommandOutput> =
      paginateListObjectsV2(
        { client: s3Client, pageSize: Number.parseInt(pageSize) },
        { Bucket: bucketName }
      );

    for await (const page of paginator) {
      if (page.Contents !== undefined) {
        for (const content of page.Contents) {
          if (content.Key !== undefined) {
            objects.push(content.Key);
          }
        }
      }
    }
    return objects;
  } catch (e) {
    throw e;
  }
};

export { uploadFile, getFile, listFilesInBucket };

5-1-5. src/app/nextjs/nextjs10/actions.ts

"use server";

import { NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { getFile, listFilesInBucket, uploadFile } from "./handle-s3";
import { v4 as uuidv4 } from "uuid";
import path from "path";

const uploadAction = async (prevState: any, formData: FormData) => {
  const subDir: string = "test/";
  const image: FormDataEntryValue | null = formData.get("image") as File;
  if (image && image.size > 0) {
    const extname: string = path.extname(image.name);
    const uploaded: NextResponse = await uploadFile({
      keyName: subDir + uuidv4() + extname,
      image,
    });
    const resData = await uploaded.json();
    prevState.success = resData.status;
  }

  revalidatePath("/");
  return prevState;
};

const selectAction = async () => {
  const files: string[] = await listFilesInBucket();
  const urls: string[] = [];
  for (const file of files) {
    const url: string = await getFile({
      keyName: file,
    });
    urls.push(url);
  }
  return urls;
};

export { uploadAction, selectAction };

5-1-6. src/app/nextjs/nextjs10/image-provider.tsx

import { useState, createContext, Context, useContext } from "react";

export type ImageProps = {
  refresh: boolean;
  isSeleted: boolean;
};

const initial: ImageProps = {
  refresh: false,
  isSeleted: false,
};

const ImageWatchContext: Context<ImageProps> = createContext(initial);
const ImageUpdateContext: Context<any> = createContext(false);

export const ImageProvider = ({ children }: any) => {
  const [imageProps, setImageProps] = useState<ImageProps>(initial);
  return (
    <ImageWatchContext.Provider value={imageProps}>
      <ImageUpdateContext.Provider value={setImageProps}>
        {children}
      </ImageUpdateContext.Provider>
    </ImageWatchContext.Provider>
  );
};

export const useWatchImage = () => useContext(ImageWatchContext);
export const useUpdateImage = () => useContext(ImageUpdateContext);

5-1-7. src/app/nextjs/nextjs10/image-list.tsx

import { useEffect, useState } from "react";
import Image from "next/image";
import { selectAction } from "./actions";
import { ImageProps, useUpdateImage, useWatchImage } from "./image-provider";

const ImageList = () => {
  const [imageUrls, setImageUrls] = useState<string[]>();
  const imageProps: ImageProps = useWatchImage();
  const setImageProps = useUpdateImage();

  useEffect(() => {
    const getImages = async () => {
      const urls: string[] = await selectAction();
      setImageUrls(urls);
    };
    getImages();
    if (imageProps.refresh) {
      setImageProps({ ...imageProps, refresh: false });
    }
  }, [imageProps, setImageProps]);

  return (
    <>
      {imageUrls ? (
        imageUrls.map((imgUrl: string, idx: number) => (
          <Image
            key={idx}
            src={imgUrl}
            alt={`url${idx}`}
            width={100}
            height={100}
          />
        ))
      ) : (
        <div>Loading image...</div>
      )}
    </>
  );
};

export default ImageList;

5-1-8. src/app/nextjs/nextjs10/file-button.tsx

import { ChangeEvent, useRef, useState } from "react";
import { Button, Typography } from "@mui/material";
import AddPhotoAlternateIcon from "@mui/icons-material/AddPhotoAlternate";
import { ImageProps, useUpdateImage, useWatchImage } from "./image-provider";

const FileButton = () => {
  const [file, setFile] = useState<File | null>(null);
  const filePickerRef = useRef<HTMLInputElement>(null);
  const imageProps: ImageProps = useWatchImage();
  const setImageProps = useUpdateImage();

  const showFolder = () => {
    if (filePickerRef.current) {
      filePickerRef.current.click();
    }
  };

  const chooseFile = (e: ChangeEvent<HTMLInputElement>) => {
    const files: FileList | null = e.target.files;
    if (files && files.length > 0) {
      setFile(files[0]);
      setImageProps({ ...imageProps, isSeleted: true });
    }
  };

  return (
    <>
      <Button
        variant="contained"
        onClick={showFolder}
        endIcon={<AddPhotoAlternateIcon />}
      >
        Choose
        <input
          type="file"
          name="image"
          accept={"image/*"}
          ref={filePickerRef}
          onChange={(e) => chooseFile(e)}
          hidden
        />
      </Button>
      <Typography>{file?.name}</Typography>
    </>
  );
};

export default FileButton;

5-1-9. src/app/nextjs/nextjs10/upload-form.tsx

"use client";

import { useEffect, useRef } from "react";
import { useFormState } from "react-dom";

import { Button } from "@mui/material";
import SendIcon from "@mui/icons-material/Send";

import { uploadAction } from "./actions";
import FileButton from "./file-button";
import { ImageProps, useUpdateImage, useWatchImage } from "./image-provider";

const UploadForm = () => {
  const initialFormState = {
    success: false,
  };
  const [formState, formAction] = useFormState(uploadAction, {
    initialFormState,
  });
  const formRef = useRef<HTMLFormElement>(null);
  const imageProps: ImageProps = useWatchImage();
  const setImageProps = useUpdateImage();

  useEffect(() => {
    if (imageProps.refresh === false && formState.success) {
      setImageProps({ ...imageProps, refresh: true });
    }
  }, [setImageProps, formState]);

  return (
    <form
      ref={formRef}
      action={async (formData: FormData) => {
        if (imageProps.isSeleted) {
          await formAction(formData);
          setImageProps({ ...imageProps, isSeleted: false });
          formRef.current!.reset();
        } else {
          alert(`Please choose.`);
        }
      }}
    >
      <FileButton />
      <br />
      <Button type="submit" variant="contained" endIcon={<SendIcon />}>
        Upload
      </Button>
    </form>
  );
};

export default UploadForm;

5-1-10. src/app/nextjs/nextjs10/page.module.scss

.component {
  color: blue;
  & ul {
    margin-left: 20px;
    & li {
      list-style: disc;
    }
  }
}

5-1-11. src/app/nextjs/nextjs10/child.tsx

"use client";

import { Stack } from "@mui/material";
import scss from "./page.module.scss";
import UploadForm from "./upload-form";
import ImageList from "./image-list";
import { ImageProvider } from "./image-provider";

const Child = () => {
  return (
    <div className={scss.component}>
      <ImageProvider>
        <Stack spacing={1} sx={{ width: "50%" }}>
          <UploadForm />
          <ImageList />
        </Stack>
      </ImageProvider>
    </div>
  );
};

export default Child;

5-1-12. src/app/nextjs/nextjs10/page.tsx

import scss from "./page.module.scss";
import GoBack from "@/lib/components/go-back";
import Child from "./child";
import { Divider } from "@mui/material";

const Nextjs10 = () => {
  return (
    <div className={scss.component}>
      <GoBack />
      <br />
      <br />
      <ul>
        <li>AWS</li>
        <ul>
          <li>S3</li>
          <ul>
            <li>Uploading a image</li>
            <li>Fetching images</li>
            <li>Rendering images</li>
          </ul>
        </ul>
      </ul>
      <br />
      <Divider />
      <br />
      <Child />
    </div>
  );
};

export default Nextjs10;

5-1-13. src/app/nextjs/page.tsx

"use client";

import React from "react";
import { Link } from "@mui/material";
import scss from "./page.module.scss";

const Nextjs = () => {
  return (
    <div className={scss.components}>
      <ul>
        <li>
          <Link href="/nextjs/nextjs01" underline="hover">
            Nextjs01
          </Link>
        </li>
        <li>
          <Link href="/nextjs/nextjs02" underline="hover">
            Nextjs02
          </Link>
        </li>
        <li>
          <Link href="/nextjs/nextjs03" underline="hover">
            Nextjs03
          </Link>
        </li>
        <li>
          <Link href="/nextjs/nextjs04" underline="hover">
            Nextjs04
          </Link>
        </li>
        <li>
          <Link href="/nextjs/nextjs05" underline="hover">
            Nextjs05
          </Link>
        </li>
        <li>
          <Link href="/nextjs/nextjs06" underline="hover">
            Nextjs06
          </Link>
        </li>
        <li>
          <Link href="/nextjs/nextjs07" underline="hover">
            Nextjs07
          </Link>
        </li>
        <li>
          <Link href="/nextjs/nextjs08" underline="hover">
            Nextjs08
          </Link>
        </li>
        <li>
          <Link href="/nextjs/nextjs09" underline="hover">
            Nextjs09
          </Link>
        </li>
        <li>
          <Link href="/nextjs/nextjs10" underline="hover">
            Nextjs10
          </Link>
        </li>
      </ul>
    </div>
  );
};

export default Nextjs;

6. サーバーを起動

npm run dev

7. ブラウザで確認

  • http://localhost:3000

8. ディレクトリの構造

省略

9. 備考

今回はAWS S3に画像をアップロードしたり画面に表示する内容についてでした。

10. 参考

投稿者プロフィール

Sondon
開発好きなシステムエンジニアです。
卓球にハマってます。

関連記事

  1. 【NextJS】Hooks-useEffect

  2. 【新米エンジニア学習記録③】Next.jsのデプロイ

  3. 【NextJS】OAuth authentication with A…

  4. 【NextJS】NextJS・TypeScript・Apollo Cl…

  5. 【NextJS】SnackbarやLink

  6. 【NextJS】Firestore

最近の記事

制作実績一覧

  1. Checkeys