In this guide, we will learn step-by-step how to handle file uploads in a Next.js application.
You can check out the final, working application here, or explore the source code from this GitHub repository.
The final result will look like this:
This guide is divided into the following sections:
- Set up the Next.js application
- Add a simple upload button
- Set up the backend to accept and save assets
- Add basic validation and security
- Add some bells and whistles (drag-and-drop, copy-paste, progress bar)
- Why this upload module is not efficient or scalable
- Set up direct uploads from browser to ImageKit
Requirements
To complete this tutorial, we will need the following. If you are using more recent versions of any of these components, you may have to make minor adjustments. The guide also assumes you have a basic knowledge of HTML, JavaScript, and CSS.
- Node.js - version 22
- npm
- npx (comes packaged with npm; if not, then install with
npm install -g npx
)
Set up the Next.js application
Let’s set up a starter Next.js application.
Run the following command in the directory that you want the project to be created:
npx create-next-app
You will be asked a few questions related to the configuration of the project. You may answer them as you prefer.
Now, run
npm run dev
When we navigate to localhost:3000
in our browser, we should see a default home page like this:

Add a simple upload button
Let’s remove everything from src/app/page.js
, and begin building our upload interface. We start by adding a basic <input>
element to accept files.
src/app/page.js
should have the following code:
'use client'
import styles from "./page.module.css";
export default function Home() {
return (
<div className={styles.page}>
<main className={styles.main}>
<input type="file" />
</main>
</div>
);
}
The webpage at localhost:3000
should now look like this:

Now, when we click on “Browse…“ and select a file to upload, the file does get selected with its name displayed in place of “Browse…“, but we are not sending the file anywhere outside of this webpage.
Let’s fix that by adding a JavaScript “handler“ function to do something with the picked-up file. The handler function handleUpload
sends a POST request to the route /api/upload
with the file attached to the request body. We use the native FormData API to simplify this.
src/app/page.js
should have the following code:
'use client';
import styles from "./page.module.css";
export default function Home() {
const handleUpload = (file) => {
if (!file) return;
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
xhr.onload = (obj) => {
if (xhr.status === 200) {
alert("File uploaded successfully!")
} else {
alert("File could not be uploaded!")
}
};
xhr.onerror = () => {
alert("File could not be uploaded!")
};
xhr.open('POST', '/api/upload');
xhr.send(formData);
};
return (
<div className={styles.page}>
<main className={styles.main}>
<input type="file" onChange={(e) => handleUpload(e.target.files[0])} />
</main>
</div>
);
}
Set up the backend to accept and save assets
The handleUpload
function attempts to send the file to the /api/upload
API, but there is no such API endpoint. Let’s create one. Create a new file src/app/api/upload/route.js
src/app/api/upload/route.js
should have the following code:
import { writeFile, mkdir } from 'fs/promises';
import { NextResponse } from 'next/server';
import path from 'path';
export async function POST(request) {
try {
const formData = await request.formData();
const file = formData.get('file');
// error out if no file is received via FormData
if (!file) {
return NextResponse.json(
{ error: 'No file received.' },
{ status: 400 }
);
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// build the complete path to our 'public/uploads' directory
const filePath = path.join(process.cwd(), 'public/uploads', file.name);
// ensure the uploads directory exists
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, buffer);
return NextResponse.json({ message: 'File uploaded successfully!' });
} catch (error) {
// handle any unknown error
return NextResponse.json(
{ error: 'Error uploading file.' },
{ status: 500 }
);
}
}
We now have an API route at /api/upload
that accepts a file called file
in the request body, and saves it to the public/uploads/
directory in the project. Let’s try it out. Click on “Browse…“ on the webpage again, and select a file. You should see the “File uploaded successfully“ browser alert popup. In the public/uploads
directory of your project, you should see the uploaded file.
Add basic validation and security
However, the request might fail if your selected file is larger than the default limit that Next.js sets up. Let’s fix that, and allow files up to a maximum of 5 MB (or some other number) to be uploaded. Along with this, we also add a few basic validation checks on the incoming file. This is critical to protect your server from attacks via malicious file uploads. We will revisit the security aspect of file uploads in a later section. Let’s add our validations now.
Make the following changes in src/app/api/upload/route.js
:
// old imports
export async function POST(request) {
try {
// old code
// Validate file type
const allowedTypes = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'video/mp4',
'video/webm',
'video/quicktime'
];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: 'File type not allowed. Only images and videos are accepted.' },
{ status: 400 }
);
}
// Validate file size (5MB = 5 * 1024 * 1024 bytes)
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
return NextResponse.json(
{ error: 'File size exceeds 5MB limit.' },
{ status: 400 }
);
}
// Validate filename
const filename = file.name.replace(/[^a-zA-Z0-9.-]/g, '_');
if (filename !== file.name) {
return NextResponse.json(
{ error: 'Filename contains invalid characters.' },
{ status: 400 }
);
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const uploadDir = path.join(process.cwd(), 'public/uploads');
await mkdir(uploadDir, { recursive: true });
await writeFile(path.join(uploadDir, filename), buffer);
return NextResponse.json({
message: 'File uploaded successfully!',
filename: filename
});
} catch (error) {
return NextResponse.json(
{ error: 'Error uploading file.' },
{ status: 500 }
);
}
}
You should see an error being raised like this on invalid file uploads:

We have successfully created our basic file upload module!
Add some bells and whistles (drag-and-drop, copy-paste, progress bar)
Now, it’s time to make it look pretty. We will add three things:
- A progress bar for an ongoing upload
- The ability to drag and drop files for upload
- The ability to paste a file from the clipboard for upload
1. Progress bar
To display a progress bar, we use the onprogress
event handler on the XMLHttpRequest.upload
object. The event object received as an argument to this callback gives us two key numbers:
1. The number of bytes sent to the server
2. The total number of bytes that need to be sent to the server
Using these two numbers, we calculate the progress percentage of our upload. Let’s write the code for this.
Along with adding a progress bar, we apply some cosmetic changes as well.
Make the following changes to src/app/page.js
:
// old imports
import { useState, useRef } from "react"
export default function Home() {
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadStatus, setUploadStatus] = useState(null);
const [uploadStats, setUploadStats] = useState({ loaded: 0, total: 0 });
const [isUploading, setIsUploading] = useState(false);
const resetUpload = () => {
setUploadProgress(0);
setUploadStatus(null);
setUploadStats({ loaded: 0, total: 0 });
setIsUploading(false);
if (formRef.current) {
formRef.current.reset();
}
};
// modified upload handler
const handleUpload = (file) => {
// old code
setIsUploading(true);
setUploadStatus(null);
// new event callback
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const progress = (event.loaded / event.total) * 100;
setUploadProgress(progress);
setUploadStats({
loaded: event.loaded,
total: event.total
});
}
};
// set the upload progress to 0 after request completion
xhr.onload = (obj) => {
setIsUploading(false);
if (xhr.status === 200) {
setUploadStatus('success');
setUploadProgress(100);
} else {
setUploadStatus('error');
setUploadProgress(0);
}
};
// set the upload progress to 0 after request failure too
xhr.onerror = () => {
setUploadStatus('error');
setUploadProgress(0);
setIsUploading(false);
};
// old code
};
const formatBytes = (bytes) => {
if (bytes === 0) return '0 KB';
const k = 1024;
return `${(bytes / k).toFixed(1)} KB`;
};
return (
<div className={styles.page}>
<main className={styles.main}>
<div className={`${styles.uploadArea} ${isUploading ? styles.disabled : ''} ${(uploadProgress > 0 || uploadStatus) ? styles.withProgress : ''}`}>
<p className={styles.uploadText}>
Select an image or video to upload<br />
</p>
<label className={`${styles.fileInputLabel} ${isUploading ? styles.disabled : ''}`}>
<input
type="file"
name="file"
className={styles.fileInput}
disabled={isUploading}
onChange={(e) => handleUpload(e.target.files[0])}
/>
Choose a file
</label>
{(uploadProgress > 0 || uploadStatus) && (
<div className={styles.uploadProgress}>
{uploadStatus ? (
<div className={`${styles.uploadStatus} ${styles[uploadStatus]}`}>
{uploadStatus === 'success' ? (
<>
<p>✓ Upload completed successfully!</p>
<button type="button" className={styles.restartButton} onClick={resetUpload}>
↺ Upload another file
</button>
</>
) : (
<>
<p>✕ Upload failed. Please try again.</p>
<button type="button" className={styles.restartButton} onClick={resetUpload}>
↺ Try again
</button>
</>
)}
</div>
) : (
<>
<div className={styles.uploadProgressHeader}>
<div className={styles.fileIcon}>📄</div>
<div>Uploading...</div>
</div>
<div className={styles.progressContainer}>
<div
className={styles.progressBar}
style={{ width: `${uploadProgress}%` }}
/>
</div>
<div className={styles.progressStats}>
<span>{formatBytes(uploadStats.loaded)} / {formatBytes(uploadStats.total)}</span>
<span>{Math.round(uploadProgress)}%</span>
</div>
</>
)}
</div>
)}
</div>
</main>
</div>
);
}
We also need some CSS to make the progress bar look nice. Add the following CSS to the end of src/app/page.module.css
.progressContainer {
width: 100%;
margin-bottom: 1rem;
background: rgba(0, 112, 243, 0.08);
border-radius: 4px;
overflow: hidden;
}
.progressBar {
height: 6px;
background: linear-gradient(90deg, #0070f3, #00a3ff);
transition: width 0.3s ease-out;
border-radius: 3px;
}
.uploadProgress {
border: 1px solid rgba(0, 112, 243, 0.1);
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
background: rgba(0, 112, 243, 0.03);
backdrop-filter: blur(8px);
}
.uploadProgressHeader {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.75rem;
color: #666;
font-size: 0.9rem;
}
.fileIcon {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 112, 243, 0.08);
border-radius: 6px;
color: #0070f3;
font-size: 1rem;
}
.progressStats {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
color: #666;
margin-top: 0.5rem;
}
.uploadStatus {
text-align: center;
padding: 1rem;
}
.uploadStatus.success {
color: #16a34a;
}
.uploadStatus.error {
color: #dc2626;
}
.restartButton {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: #f5f5f5;
color: #666;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
margin-top: 1rem;
transition: all 0.2s ease;
}
.restartButton:hover {
background: #e5e5e5;
}
@media (prefers-color-scheme: dark) {
.uploadProgress {
border-color: rgba(0, 112, 243, 0.2);
background: rgba(0, 112, 243, 0.05);
}
.fileIcon {
background: rgba(0, 112, 243, 0.15);
color: #3291ff;
}
.progressContainer {
background: rgba(0, 112, 243, 0.15);
}
.uploadProgressHeader {
color: #999;
}
.restartButton {
background: #262626;
color: #999;
}
.restartButton:hover {
background: #333;
}
}
.uploadArea {
padding: 2rem;
border: 2px dashed #ccc;
border-radius: 8px;
text-align: center;
background: #fafafa;
transition: all 0.2s ease;
cursor: pointer;
width: 100%;
min-height: 200px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.5rem;
}
.uploadArea.withProgress {
min-height: 320px;
transition: min-height 0.3s ease-out;
}
.uploadArea.disabled {
opacity: 0.7;
cursor: not-allowed;
}
.uploadArea.disabled:hover {
border-color: #ccc;
background: #fafafa;
}
.uploadText {
margin: 0;
color: #666;
}
.uploadText span {
display: block;
margin: 0.25rem 0;
color: #999;
}
.fileInput {
display: none;
}
.fileInputLabel {
display: inline-block;
padding: 0.5rem 1rem;
background: #0070f3;
color: white;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s ease;
}
.fileInputLabel:hover {
background: #0060df;
}
.fileInputLabel.disabled {
opacity: 0.5;
cursor: not-allowed;
background: #999;
}
.fileInputLabel.disabled:hover {
background: #999;
}
File uploads will now show a progress bar. However, the progression in the progress bar may not be noticeable. This is because the upload begins and completes almost instantly since we are using a local backend to handle the upload. You can use the throttling option in the “Network” tab of the developer tools in your browser. Setting the throttling level to “Good 3G“ or “Regular 3G“ (on Firefox) does the trick for me. The upload is slowed down now and the progress bar takes its time to fill up.
Here’s what it looks like:

2. Drag and Drop
Let’s add the ability to drag and drop a file into the page to initiate the upload. Achieving this is rather simple without the need for an external package. We use the native HTML onDragOver
, onDragLeave
, and onDrop
events to enable this feature.
src/app/page.js
should have the following code:
// old imports
export default function Home() {
// old state definitions
const [isDragging, setIsDragging] = useState(false);
// old code
// use a designated callback for the vanilla HTML <input> element
const handleFileInput = (e) => {
const file = e.target.files[0];
handleUpload(file);
};
// new handlers for drag-and-drop
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const file = e.dataTransfer.files[0];
handleUpload(file);
};
return (
// attach the drag-and-drop handlers to the main, outermost div
<div
className={styles.page}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
// change the main element to incorporate drag-and-drop
{isDragging && !isUploading && (
<div className={styles.dropZone}>
<div className={styles.dropZoneContent}>
Drop your file here
</div>
</div>
)}
<main className={styles.main}>
<div className={styles.uploadArea}>
<p className={styles.uploadText}>
Drag and drop your file here<br />
<span>or</span>
</p>
<label className={styles.fileInputLabel}>
<input
type="file"
onChange={(e) => handleUpload(e.target.files[0])}
className={styles.fileInput}
/>
Choose a file
</label>
// old code
</div>
);
}
We need to add some styling too to make the UI of the drag-and-drop look intuitive.
Add the following classes to src/app/page.module.css
:
.dropZoneContent {
padding: 2rem 4rem;
background: white;
border: 2px dashed #0070f3;
border-radius: 8px;
font-size: 1.25rem;
color: #0070f3;
}
.dropZone {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 112, 243, 0.05);
backdrop-filter: blur(2px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
The page should look like this now:

3. Paste from clipboard
Let’s add a third method to upload files on the page - Pasting a file from the clipboard. Most modern browsers and operating systems support pasting files into a webpage using the paste shortcut (ctrl/cmd + v). But our webpage needs to have the JavaScript code to detect the paste and handle the file. Let’s add that code!
src/app/page.js
should have the following code:
// old imports
import { useEffect, useState } from "react";
export default function Home() {
// old state definitions
// old code
// attach an event listener for the "paste" event. capture and upload the file in this handler
useEffect(() => {
const handlePaste = (e) => {
e.preventDefault();
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.kind === 'file') {
const file = item.getAsFile();
handleUpload(file);
break;
}
}
};
window.addEventListener('paste', handlePaste);
return () => {
window.removeEventListener('paste', handlePaste);
};
}, []);
return (
// old code
<main className={styles.main}>
<div className={`${styles.uploadArea} ${isUploading ? styles.disabled : ''} ${(uploadProgress > 0 || uploadStatus) ? styles.withProgress : ''}`}>
<p className={styles.uploadText}>
Drag and drop your file here<br />
<span>or</span>
</p>
// add some text to let the user know they can paste files for uploading
<p className={styles.uploadHint}>
You can also paste files using Ctrl+V / Cmd+V
</p>
<label className={styles.fileInputLabel}>
// old code
</label>
</div>
</main>
// old code
);
}
Add the following class to src/app/page.module.css
.uploadHint {
margin: 0.5rem 0;
font-size: 0.9rem;
color: #666;
font-style: italic;
}
The webpage should now look like this, and pasting a copied file should successfully upload it.

Why this upload module is not efficient or scalable
We are currently storing files locally, but this won't work if our application becomes popular. We need a solution that can handle millions of files, terabytes of storage, and hundreds of requests per second. The problem is even bigger with videos since they are large files and take a long time to upload. If our users are from different parts of the world, we need to run our backend in multiple regions to ensure a good user experience. While a DIY approach is good for learning, it isn't practical for real-world applications.
Let's look at some common problems and explore solutions for them:
Limited File Storage on Servers
- Files are stored on a file system, and they have limited disk space. Scaling up requires increasingly larger disks.
- Local disks can only be attached to one server, preventing multiple application instances.
- What we need is a globally distributed upload backend that can manage billions of files, terabytes of storage, and hundreds of requests per second.
High Latency with Large File Uploads
- Large files like videos take longer to upload.
- Users in different regions may experience high latencies when uploading to a single region.
- We can deploy and run the upload backend in multiple regions to solve this.
Centralization of the file store
- In our DIY setup, the uploaded files are stored at a single, centralized location. If you have multiple instances of your upload API servers running, they must all upload to this same central store.
- This becomes a bottleneck for performance, besides being a single point of failure.
- We need the flexibility of being able to use multiple storage systems. Using a service like AWS S3 for the actual file storage makes it a lot more reliable.
File Delivery
- After uploading, delivering files to users requires another application layer.
- We need an application that sits in front of the storage, scales automatically, handles very high request rates, and has caching integrated in it so we don’t overburden our storage system.
Security Concerns
- File uploads can be entry points for hackers to inject malicious files. This could compromise the entire cloud infrastructure.
- We need to decouple the file upload, storage, and delivery systems from our main application containing the business logic.
ImageKit.io is a third-party service that can handle all your file upload and storage needs, solving each of the above problems. Let’s see how:
- File Storage: ImageKit can handle billions of files, terabytes of storage, and hundreds of requests per second.
- Latency: ImageKit offers a global upload API endpoint. Upload requests are routed to the nearest of six strategically located regions to minimize latency.
- Decentralization: The upload endpoint is highly available and distributed so that the file upload does not become a point of failure for your application. Moreover, ImageKit allows you to use multiple storage services for the actual file storage.
- Delivery: ImageKit provides a robust delivery API and supports high request rates with multiple layers of caching. It offers features such as intelligent image optimization, resizing, cropping, watermarking, file format conversion, video optimization, and streaming.
- Security: With ImageKit, you offload file handling to an external service to protect your servers from attacks. This allows you to decouple upload, storage, and delivery from the rest of your application.
Let’s integrate ImageKit into our upload module.
Set up direct uploads from browser to ImageKit
Let's do everything again, but this time using ImageKit with a few lines of code. The overall functionality will remain unchanged. To ease the development, we will use ImageKit Next.js SDK. In this case, we will upload files to ImageKit directly from the browser.
With this integration, our frontend upload interface remains unchanged. Our backend upload API becomes completely redundant. The webpage will now send the file directly to ImageKit’s public upload endpoint, authenticated by your ImageKit API key.
Set up the ImageKit Next.js SDK
Installing the ImageKit Next.js SDK in our app is pretty simple:
npm install --save imagekitio-next imagekit
Before adding ImageKit-related code to the project, let’s split our website into two different routes. One for native uploads that we have built so far, and another for ImageKit uploads that we are about to build. Create two new files src/app/native-upload/page.js
, and src/app/imagekit-upload/page.js
. Move everything currently in src/app/page.js
into src/app/native-upload/page.js
, and overwrite the src/app/page.js
file with the following code. Don’t forget to update the import path of the CSS file to ../page.module.css
.
'use client';
import styles from "./page.module.css";
import Link from 'next/link';
export default function Home() {
return (
<div className={styles.homePage}>
<div className={styles.demoContainer}>
<h1>Next.js upload file demo</h1>
<Link href="/native-upload" className={styles.demoLink}>
Native Next.js upload demo
</Link>
<Link href="/imagekit-upload" className={styles.demoLink}>
ImageKit Next.js upload demo
</Link>
</div>
</div>
);
}
Also, add the following new styles to src/app/page.module.css
.demoContainer {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.demoContainer h1 {
margin-bottom: 2rem;
text-align: center;
color: #000033;
}
.demoLink {
display: block;
padding: 1rem;
margin: 1rem 0;
border: 2px dashed #4285f4;
border-radius: 4px;
text-align: center;
color: #4285f4;
text-decoration: none;
transition: all 0.3s ease;
}
.demoLink:hover {
background: #4285f4;
color: white;
}
.homePage {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
Initialize the Next.js SDK:
Before using the SDK, let's learn how to obtain the necessary initialization parameters:
urlEndpoint
is a required parameter. This can be obtained from the URL-endpoint section or the developer section on your ImageKit dashboard.publicKey
andauthenticator
parameters are needed for client-side file upload.publicKey
can be obtained from the Developer section on your ImageKit dashboard.authenticator
expects an asynchronous function that resolves with an object containing the necessary security parameters i.esignature
,token
, andexpire
. We will see how to generate these in the following section.
To integrate uploads to ImageKit in our application, we will need to define and add a bunch of new variables and functions. Let’s add them and understand why each change is necessary.
Overwrite the code inside src/app/imagekit-upload/page.js
with the following:
'use client';
import { ImageKitProvider, IKUpload } from "imagekitio-next";
import styles from "../page.module.css";
import { useState, useEffect, useRef, useCallback } from "react";
export default function Home() {
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadStatus, setUploadStatus] = useState(null);
const [uploadStats, setUploadStats] = useState({ loaded: 0, total: 0 });
const [isUploading, setIsUploading] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const ikUploadRef = useRef(null)
// This function fires a "change" event on the IKUpload's internal <input> element
const uploadViaIkSdk = useCallback((files) => {
if (ikUploadRef?.current) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'files').set;
nativeInputValueSetter.call(ikUploadRef.current, files);
const changeEvent = new Event('change', { bubbles: true });
ikUploadRef.current.dispatchEvent(changeEvent);
}
}, [ikUploadRef])
// Call our backend API to generate short-lived authentication credentials using our ImageKit API key
const authenticator = async () => {
try {
const response = await fetch("/api/auth");
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Request failed with status ${response.status}: ${errorText}`);
}
const data = await response.json();
const { signature, expire, token } = data;
return { signature, expire, token };
} catch (error) {
throw new Error(`Authentication request failed: ${error.message}`);
}
};
// This function now calls 'uploadViaSdk' to trigger the "change" event on IKUpload
useEffect(() => {
const handlePaste = (e) => {
e.preventDefault();
const files = e.clipboardData?.files;
if (!files || files.length === 0) return;
uploadViaIkSdk(files)
};
window.addEventListener('paste', handlePaste);
return () => {
window.removeEventListener('paste', handlePaste);
};
}, []);
// event handlers for IKUpload: onError, onProgress, and onSuccess
const onError = (err) => {
setUploadStatus('error');
setUploadProgress(0);
setIsUploading(false);
};
const onProgress = (e) => {
if (e.lengthComputable) {
const progress = (e.loaded / e.total) * 100;
setUploadProgress(progress);
setUploadStats({
loaded: e.loaded,
total: e.total
});
}
};
const onSuccess = (res) => {
setIsUploading(false);
setUploadStatus('success');
setUploadProgress(100);
};
const resetUpload = () => {
setUploadProgress(0);
setUploadStatus(null);
setIsUploading(false);
};
const formatBytes = (bytes) => {
if (bytes === 0) return '0 KB';
const k = 1024;
return `${(bytes / k).toFixed(1)} KB`;
};
const handleDragOver = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
// This function now calls 'uploadViaSdk' to trigger the "change" event on IKUpload
const handleDrop = (e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
uploadViaIkSdk(e.dataTransfer.files)
};
return (
// The main <div> is wrapped under this provider to make ImageKit-related variables available
<ImageKitProvider
publicKey={process.env.NEXT_PUBLIC_PUBLIC_KEY}
urlEndpoint={process.env.NEXT_PUBLIC_URL_ENDPOINT}
authenticator={authenticator}
>
{/* The <IKUpload> is internally simply an <input> file picker. But since we have our own three upload UI interfaces, ...
... we hide the <IKUpload> element, and just reference it to manually trigger a “change“ event on it. */}
<IKUpload
onError={onError}
onSuccess={onSuccess}
onUploadProgress={onProgress}
// we use this ref to manually trigger the "change" event on this element
ref={ikUploadRef}
style={{visibility: 'hidden', height: 0, width: 0}} // hide the default button
/>
<div
className={styles.page}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{ikUploadRef && (
<>
{isDragging && !isUploading && (
<div className={styles.dropZone}>
<div className={styles.dropZoneContent}>
Drop your file here
</div>
</div>
)}
<main className={styles.main}>
<div className={`${styles.uploadArea} ${isUploading ? styles.disabled : ''} ${(uploadProgress > 0 || uploadStatus) ? styles.withProgress : ''}`}>
<p className={styles.uploadText}>
Drag and drop your file here<br />
<span>or</span>
</p>
<p className={styles.uploadHint}>
You can also paste files using Ctrl+V / Cmd+V
</p>
<label className={`${styles.fileInputLabel} ${isUploading ? styles.disabled : ''}`}>
<input
type="file"
name="file"
className={styles.fileInput}
disabled={isUploading}
// This function now calls 'uploadViaSdk' to trigger the "change" event on IKUpload
onChange={(e) => {
e.stopPropagation()
e.preventDefault()
uploadViaIkSdk(e.target.files)
}}
/>
Choose a file
</label>
{(uploadProgress > 0 || uploadStatus) && (
<div className={styles.uploadProgress}>
{uploadStatus ? (
<div className={`${styles.uploadStatus} ${styles[uploadStatus]}`}>
{uploadStatus === 'success' ? (
<>
<p>✓ Upload completed successfully!</p>
<button type="button" className={styles.restartButton} onClick={resetUpload}>
↺ Upload another file
</button>
</>
) : (
<>
<p>✕ Upload failed. Please try again.</p>
<button type="button" className={styles.restartButton} onClick={resetUpload}>
↺ Try again
</button>
</>
)}
</div>
) : (
<>
<div className={styles.uploadProgressHeader}>
<div className={styles.fileIcon}>📄</div>
<div>Uploading...</div>
</div>
<div className={styles.progressContainer}>
<div
className={styles.progressBar}
style={{ width: `${uploadProgress}%` }}
/>
</div>
<div className={styles.progressStats}>
<span>{formatBytes(uploadStats.loaded)} / {formatBytes(uploadStats.total)}</span>
<span>{Math.round(uploadProgress)}%</span>
</div>
</>
)}
</div>
)}
</div>
</main>
</>
)}
</div>
</ImageKitProvider>
);
}
Create a file .env
at the root of your project and add the following to it:
NEXT_PUBLIC_PUBLIC_KEY=<your_imagekit_public_key>
NEXT_PUBLIC_URL_ENDPOINT=<your_imagekit_url_endpoint>
PRIVATE_KEY=<your_imagekit_private_key>
Create a file src/app/api/auth/route.js
and add the following to it:
import ImageKit from "imagekit";
import { NextResponse } from "next/server";
const imagekit = new ImageKit({
publicKey: process.env.NEXT_PUBLIC_PUBLIC_KEY,
privateKey: process.env.PRIVATE_KEY,
urlEndpoint: process.env.NEXT_PUBLIC_URL_ENDPOINT,
});
export async function GET(request) {
const authParams = imagekit.getAuthenticationParameters()
return NextResponse.json(authParams);
}
Let’s describe all of the changes and the reason for making them:
- The
<div>
in the return statement is now wrapped inside a<ImageKitProvider>
, which sets up the context with required ImageKit-related variables. - The
<IKUpload>
is internally simply an<input>
file picker. But since we have our own three upload UI interfaces, we hide the<IKUpload>
element and just reference it to manually trigger a “change“ event on it. ikUploadRef
is used to reference and control the<IKUpload>
element. This is required to manually trigger the “change“ event on this element.- The
authenticator
function calls a backend API (which we will write next) to generate authentication parameters for the file upload. This is required because you can authenticate to ImageKit only via your account private key, and we never store our private key in the frontend code. - The
uploadViaIkSdk
function fires a “change” event on the<IKUpload>
element. The “change” event triggers the HTTP requests for authentication and finally, the actual upload. This is required because we have three different UI handlers to initiate uploads, and they all need a common interface to trigger the<IKUpload>
element’s change handler. onSuccess
,onError
,onProgress
are callbacks to handle the respective events received from<IKUpload>
handleDrop
now callsuploadViaIkSdk
instead of the previoushandleFileInput
, which has been removed completely now.- The “paste“ event handler now calls
uploadViaIkSdk
instead of the previoushandleFileInput
, which has been removed completely. - The
onChange
event handler on the<input>
element now also callsuploadViaIkSdk
instead ofhandleFileInput
Here’s how the flow of our uploads works now:

- When a file is sent for upload via any of the three methods, a “change“ event on the
<IKUpload>
element is fired. - The “change“ event internally triggers two HTTP requests. One to our own application’s backend to generate the authentication parameters, and another to ImageKit’s public upload endpoint for the actual upload.
- The upload finishes successfully (or fails) and the corresponding callbacks that we sent to
<IKUpload>
are invoked.
Following these changes, the visual output on the webpage should remain exactly the same. The only change is in the upload destination. The files are now uploaded to your ImageKit account instead of your local project directory.
Validation
When uploading to ImageKit, we have the option of writing validation checks both for the frontend and the backend. Backend checks are much more secure than frontend checks since they are executed on your application server. Let’s see how to implement both in our upload module via ImageKit.
- Backend checks: The
<IKUpload>
component accepts achecks
prop that has a string value. This string represents what server-side checks must be performed before the file is uploaded. Learn about upload checks in detail here. For example,"request.folder : "offsites/"
will limit file uploads to the folderoffsites
and its sub-folders. - Frontend checks: The
<IKUpload>
component accepts a function for the propvalidateFile
. Let’s utilize that to run a basic frontend file validation check.
The corresponding code changes in src/app/imagekit-upload/page.js
for this are as follows:
// define two state variables to track frontend validation errors
const [isFileValid, setIsFileValid] = useState(true)
const [fileValidationError, setFileValidationError] = useState(undefined)
// code to run the validations
const validateFile = (file) => {
if (file?.size > 5 * 1024 * 1024) {
setIsFileValid(false)
setFileValidationError("File must be less than 5MB in size.")
return false
}
if (!file?.type?.startsWith("image/") && !file?.type?.startsWith("video/")) {
setIsFileValid(false)
setFileValidationError("File must be an image or a video.")
return false
}
setIsFileValid(true)
setFileValidationError(undefined)
return true
}
// show an alert if validation fails
useEffect(() => {
if (!isFileValid) {
alert(fileValidationError ?? "File is not valid. Please make sure it is an image or a video, and less than 5MB in size.")
setIsFileValid(true)
setFileValidationError(undefined)
}
}, [isFileValid, fileValidationError])
// Assign the `validateFile` and `checks` props to IKUpload
<IKUpload
...
validateFile={validateFile}
checks={'"request.folder":"offsites/"'} ...
>
Bonus: Optimize, transform, and deliver the uploaded image/video
Now that you have safely uploaded your image or video to ImageKit, let’s see how ImageKit helps with the file’s delivery flow. ImageKit can do three (among many other) things when delivering your image or video to your frontend clients:
- Optimize: ImageKit will convert the image or video into the format that has the lowest file size before delivering it.
- Transform: You can perform a host of transformations on the image/video like resizing, cropping, rotating, and a lot more.
- Cache: ImageKit caches all your assets on the CDN by default. Even the transformed and optimized versions of them!
Here’s some Next.js code to do all of the above things:
/**
* This fetches an image from "https://ik.imagekit.io/<your_imagekit_id>/<your_url_endpoint_identifier>/default-image.jpg",
* optimizes it to get the lowest possible file size, applies the resize and rotation transformations,
* caches it at the CDN, and finally delivers it to this webpage.
** /
<ImageKitProvider urlEndpoint="https://ik.imagekit.io/<your_imagekit_id>/<your_url_endpoint_identifier>/"
>
<IKImage
path="/default-image.jpg"
transformation={[
{
"height": "200",
"width": "200",
},
{
"rotation": "90"
}
]}
alt="Alt text"
/>
</ImageKitProvider>
Conclusion
In this tutorial, we've covered:
- Building a file upload application in Next.js from scratch.
- Implementing file type and size validations for secure uploads.
- Transitioning to ImageKit.io for direct uploads from the browser, making our application more efficient and scalable.
- Utilizing ImageKit's free upload API and Next.js SDK for a streamlined experience with minimal code.
You can check out the live demo on CodeSandbox and explore the code on Github.