【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】Gemini CLIを使ってシンプルプロジェクトを開発…

  2. 【NextJS】VSCodeにGemini Code Assistを連…

  3. 【NextJS】日程調整からグループ分け、精算までPankoが便利です…

  4. 【NextJS】Redux

  5. 【NextJS】Checkbox・Radio Group

  6. 【NextJS】Local Storage

最近の記事

制作実績一覧

  1. Checkeys