Next.js + TypeScript にて画像アップロード機能を実装する

Next.js + TypeScript にて API に画像をアップロードする機能のサンプルコード。

やること

  • 画像ファイルをバックエンドに POST で送信するような機能を実装。
  • HTTP Client は Next.js で用意されている fetch - Next.js を使う。

環境

  • Next.js v15
  • TypeScript v5
  • React v18
  • Sass v1.8
  • App Router

構成

/src
  ├ /app
  │  └ page.tsx
  ├ /components
  │  └ /elements
  │     └ /input
  │        ├ index.tsx
  │        └ styles.module.scss
  │ /database
  │  └ imageRepository.ts
  ├ /features
  │  └ /fileUploader
  │     ├ index.tsx
  │     ├ repository.ts
  │     └ styles.module.scss
  └ /infrastructure
     └ fetch.ts

サンプルコード

page.tsx

コンポーネントの FileUploader を呼び出すだけ。

import FileUploader from "@/features/fileUploader"

export default async function Page() {
	return <FileUploader />
}

src/components/elements/input/index.tsx

input タグの type が file の時の見た目があまりよろしくないので、
label でボタンを作る。

"use client"

import { useId } from "react"
import styles from "./styles.module.scss"

type Props = {
	value: string
	type?: Type
	onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
}

type Type = "text" | "file"

export default function Input({ value, type = "text", onChange = () => {} }: Props) {
	const id: string = useId()
	return (
		<>
			{type == "file" && (
				<label htmlFor={id} data-testid="label" className={styles["label-upload"]}>
					ファイルを選択
				</label>
			)}
			<input id={id} data-testid="input" className={styles.input} type={type} value={value} onChange={onChange} />
		</>
	)
}

src/components/elements/input/styles.module.scss

大事なところだけ。

.input {
	&[type="file"] {
		display: none;
	}
}

.label {
	&-upload {
		display: inline-block;
		cursor: pointer;
	}
}

src/infrastructure/fetch.ts

fetch のラッパー。
headers の content-type は指定しない方が良いかも。
options.cache も指定しない方が良いかも。

下記の書き方では、
GET 以外の content-type は下記のようになる。
multipart/form-data; boundary=----formdata-undici-xxxxxxx

GET の場合の content-type は空文字になる。

"use server"

enum Method {
	Get = "GET",
	Post = "POST",
	Patch = "PATCH",
	Put = "PUT",
	Delete = "DELETE",
}

type FetchRequest<T> = {
	url: string
	params: T
	headers?: HeadersInit
}

export async function post<T>({ url, params, headers = {} }: FetchRequest<T>): Promise<Response> {
	return await client<T>(Method.Post, url, params, headers)
}

async function client<T>(method: Method, url: string, params: T, headers: HeadersInit): Promise<Response> {
	if (method == Method.Get) {
		return await fetch(`${url}?${createData<T, URLSearchParams>(params, new URLSearchParams()).toString()}`, {
			method: method,
			mode: "cors",
			headers: headers,
		})
	}
	return await fetch(url, {
		body: createData<T, FormData>(params, new FormData()),
		method: method,
		mode: "cors",
		headers: headers,
	})
}

function createData<T, U>(params: T, webApi: U): U {
	const query: object = params && typeof params == "object" ? params : {}
	for (const [key, value] of Object.entries(query)) {
		if (Array.isArray(value)) {
			for (const [_, v] of value.entries()) {
				if (webApi instanceof URLSearchParams || webApi instanceof FormData) {
					webApi.append(key, v)
				}
			}
			continue
		}
		if (webApi instanceof URLSearchParams || webApi instanceof FormData) {
			webApi.append(key, value)
		}
	}
	return webApi
}

src/database/imageRepository.ts

response.json() した時に Object になる前提で。
API の URL は適当。

"use server"

import { post } from "@/infrastructure/fetch"

export type UploadParams = {
	image: File
}

export async function uploadImage(params: UploadParams): Promise<object> {
	try {
		const response: Response = await post<UploadParams>({ url: "https://sample.com/uploads", params: params })
		return await response.json()
	} catch (e) {
		console.log(e)
		return {}
	}
}

src/features/fileUploader/index.tsx

簡易的なアップローダー。
Image の width と height は必須なので値を入れているけど、
実際のサイズは css で調整する。

"use client"

import { useState as UseState } from "react"
import Image from "next/image"

import { upload } from "./repository"

import Input from "@/components/elements/input"
import styles from "./styles.module.scss"

export default function FileUploader() {
	const [thumbnail, setThumbnail] = UseState("")
	const [inputValue, setInputValue] = UseState<File>(new File([], ""))

	function onChange(e: React.ChangeEvent<HTMLInputElement>) {
		e.preventDefault()

		if (e.target.files == null) return

		const files: FileList = e.target.files

		if (files.length == 0) {
			return
		}

		const file: File = files[0]
		const reader: FileReader = new FileReader()

		switch (file.type) {
			case "image/webp":
			case "image/jpeg":
			case "image/gif":
			case "image/png":
				break
			default:
				alert(`画像ファイルを選択してください(${file.type})`)
				return
		}

		reader.onload = (_: ProgressEvent) => {
			if (reader.result == null) return

			setInputValue(file)

			if (typeof reader.result == "string") {
				setThumbnail(reader.result)
			}
		}
		reader.onerror = (e: ProgressEvent) => {
			console.log(e)
		}
		reader.readAsDataURL(file)
		// ここで value を空にすることで、
		// 再度同じ画像を選択しても onChange が呼ばれるようになる。
		e.target.value = ""
	}

	async function send(e: React.MouseEvent<HTMLButtonElement>): Promise<void> {
		e.preventDefault()
		if (inputValue && thumbnail) {
			const res = await upload({ image: inputValue })
			console.log(res)
		}
	}

	function clear(e: React.MouseEvent<HTMLButtonElement>) {
		e.preventDefault()
		setThumbnail("")
	}

	return (
		<>
			<p>画像アップロード</p>
			{thumbnail && (
				<Image
					width={0}
					height={0}
					src={thumbnail}
					alt="upload file"
					className={styles.image}
				/>
			)}

			<form>
				<ul>
					<li>
						<Input type="file" onChange={onChange} value="" />
					</li>
					<li>
						<button onClick={send}>
							send
						</button>
						<button onClick={clear}>
							clear
						</button>
					</li>
				</ul>
			</form>
		</>
	)
}

src/features/fileUploader/repository.ts

"use server"

import type { UploadParams } from "@/database/imageRepository"
import { uploadImage } from "@/database/imageRepository"

export async function upload(params: UploadParams): Promise<object> {
	return await uploadImage(params)
}

src/features/fileUploader/styles.module.scss

.image {
	max-width: 300px;
	width: auto;
	height: auto;
}