route.ts
import mime from "mime";
import { join } from "path";
import { writeFile } from "fs/promises";
const UPLOAD_DIR = join(process.cwd(), "public/uploads");
export async function POST(request: Request) {
const formData = await request.formData();
const files = formData.getAll("files");
const uploadFilePromises: Promise<string | false>[] = [];
if (files.length === 0) {
return Response.json({ uploadedFiles: [] });
}
for (const file of files) {
if (!(file instanceof Blob)) {
continue;
}
uploadFilePromises.push(uploadFile(file));
}
return Response.json({
uploadedFiles: (await Promise.all(uploadFilePromises)).filter(
(uploadedFile) => uploadedFile !== false
),
});
}
async function uploadFile(file: Blob): Promise<string | false> {
const buffer = Buffer.from(await file.arrayBuffer());
const uniqueSuffix = `${Date.now()}-${Math.round(
Math.random() * 1e9
)}`;
const filenameParts: string[] = [];
if ("name" in file && typeof file.name === "string") {
filenameParts.push(file.name.replace(/\.[^/.]+$/, ""));
}
filenameParts.push(
`${uniqueSuffix}.${mime.getExtension(file.type)}`
);
try {
const filename = filenameParts.join("-");
await writeFile(`${UPLOAD_DIR}/${filename}`, buffer);
return `/uploads/${filename}`;
} catch (e) {
console.error(
`Error while trying to upload the file: ${filenameParts[0]}.\n`,
e
);
return false;
}
}
page.tsx
import { FileUploader } from "@/components";
export default function Page() {
return (
<div className="space-y-3">
<h1 className="text-xl font-bold">File Uploader Form</h1>
<FileUploader apiUrl="/api/upload" />
</div>
);
}
globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
layout.tsx
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title:
"Codersteps project for Building a File Uploader with Next.js App Router",
description:
"Codersteps project for Building a File Uploader with Next.js App Router",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="h-screen">
<main className="h-full flex items-center justify-center">
<div className="w-full max-w-xl space-y-5">{children}</div>
</main>
</body>
</html>
);
}
page.tsx
import Link from "next/link";
export default function Home() {
return (
<div>
<h1 className="text-2xl font-bold">
Codersteps project for Building a File Uploader with Next.js App Router
</h1>
<div>
<Link
className="text-sm font-medium text-blue-500 hover:underline"
href="/uploader"
>
Go to the Uploader page
</Link>
</div>
</div>
);
}
FileUploader.tsx
"use client";
import Image from "next/image";
import { ChangeEvent, useState } from "react";
export function FileUploader({ apiUrl }: { apiUrl: string }) {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [uploadedFiles, setUploadedFiles] = useState<string[]>([]);
async function uploadSelectedFiles() {
if (apiUrl.trim().length === 0) {
console.warn("Please provide a valid apiUrl.");
return [];
}
if (selectedFiles.length === 0) {
return [];
}
const formData = new FormData();
for (const file of selectedFiles) {
formData.append("files", file);
}
try {
const res = await fetch(apiUrl, {
method: "POST",
body: formData,
});
if (!res.ok) {
console.error("something went wrong, check your console.");
return [];
}
const data: { uploadedFiles: string[] } = await res.json();
return data.uploadedFiles;
} catch (error) {
console.error("something went wrong, check your server/client console.");
}
return [];
}
async function onChange(e: ChangeEvent<HTMLInputElement>) {
const { files } = e.target;
if (!files || files.length === 0) {
console.warn("files list is empty");
return;
}
setSelectedFiles(Array.from(files));
}
async function onUpload() {
setUploadedFiles(await uploadSelectedFiles());
console.log("All files were uploaded successfully.");
setSelectedFiles([]);
}
function onClear() {
setSelectedFiles([]);
setUploadedFiles([]);
}
if (selectedFiles.length === 0 && uploadedFiles.length === 0) {
return (
<label className="border border-dashed border-[#666666] hover:border-black rounded bg-gray-100 hover:bg-gray-200 text-[#666666] hover:text-black transition-colors duration-300 h-48 flex items-center justify-center">
<span className="text-sm font-medium">Select an Image</span>
<input
className="h-0 w-0"
type="file"
accept="image/*"
multiple
onChange={onChange}
/>
</label>
);
}
if (selectedFiles.length > 0 || uploadedFiles.length > 0) {
return (
<div className="border border-dashed border-gray-700 hover:border-black rounded">
<div className="grid grid-cols-2 gap-3 p-3">
{selectedFiles.map((selectedFile, idx) => (
<div key={idx}>
<Image
src={URL.createObjectURL(selectedFile)}
alt={selectedFile.name}
width={500}
height={500}
quality={100}
className="object-cover w-full h-auto"
/>
</div>
))}
{uploadedFiles.map((uploadedFile, idx) => (
<div key={idx}>
<Image
src={uploadedFile}
alt="An uploaded file"
width={500}
height={500}
quality={100}
className="object-cover w-full h-auto"
/>
</div>
))}
</div>
<div className="flex items-center justify-end gap-x-3 border-t border-dashed p-3 border-gray-700">
<button
type="button"
onClick={onClear}
className="bg-zinc-300 hover:bg-zinc-200 transition-colors duration-300 px-3 h-10 rounded"
>
Clear
</button>
{uploadedFiles.length === 0 && (
<button
type="button"
onClick={onUpload}
className="bg-sky-500 hover:bg-sky-600 transition-colors duration-300 text-white px-3 h-10 rounded"
>
Upload
</button>
)}
</div>
</div>
);
}
return null;
}
index.ts
export * from "./FileUploader";
.eslintrc.json
{
"extends": "next/core-web-vitals"
}
.prettierrc
{
"printWidth": 70
}
next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;
package.json
{
"name": "codersteps",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"date-fns": "^3.6.0",
"mime": "^4.0.4",
"next": "14.2.4",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/mime": "^3.0.4",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.4",
"postcss": "^8",
"sass": "^1.77.8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}postcss.config.mjs
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {},
plugins: [],
};
export default config;
tsconfig.json
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}