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;
}