File upload to Google Cloud Storage (GCS) in Next.js 14 API Route Handler
Introduction
I’m working on a side project, Idea Drops, that lets users record and upload audio that will be transcribed and have some fun insights surfaced using AI.
I’ve decided to use Next 14 to build out this idea. I’ve wanted to take the app router for a spin for a while, but Next 13 was a bit too buggy for me.
I spent a decent amount of time on failed attempts using Server Actions, formidable, and incorrect types that didn’t sync up. The approach I got working uses the file buffer to write to the GCP writeable stream and doesn’t use any external libraries.
Code
Link to the gist here.
Code Breakdown
// components/AudioRecorder.tsx
"use client"
export default function AudioRecorder() {
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
// ...app code omitted
const uploadAudio = async () => {
if (!audioBlob) {
throw new Error("No audio to upload");
}
// Prepare FormData
let formData = new FormData();
const timestamp = Date.now();
// Append the audio blob to the FormData object. You might want to give it a filename.
formData.append("audio", audioBlob, `${timestamp}.webm`);
// Setup the fetch request options
const requestOptions: RequestInit = {
method: "POST",
body: formData,
};
// Send the request to your API endpoint
try {
const response = await fetch("/api/upload", requestOptions);
if (!response.ok) throw new Error("Failed to upload");
const data = await response.json();
console.log("Upload successful:", data);
} catch (error) {
console.error("Error uploading audio:", error);
}
};
return (
<div className="audio-container">
<form id="uploadForm">
<input type="file" id="fileInput" multiple />
<button type="button" onClick={uploadAudio}>
Upload
</button>
</form>
</div>
);
}
- Validate the presence of
audioBlob
, the Blob audio data. - Create
FormData
and append theaudioBlob
with a dynamically generated filename based on the current timestamp, ensuring uniqueness. In the app code, a user ID is also added. - Configure and send a POST request to the
/api/upload
endpoint using thefetch
API, passing theFormData
as the body.
// api/upload/route.ts
export const POST = async (req: Request, res: Response) => {
try {
const data = await req.formData();
const file = data.get("audio") as File;
if (!file || typeof file === "string") {
throw new Error("Audio file not found");
}
const filePath = file?.name;
const storage = new Storage({
projectId: `${GCP_PROJECT_ID}`,
credentials: {
client_email: `${GCP_SERVICE_ACCOUNT_EMAIL}`,
private_key: `${GCP_PRIVATE_KEY}`,
},
});
const bucket = storage.bucket(`${GCP_BUCKET_NAME}`);
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Wrap the upload logic in a promise
await new Promise((resolve, reject) => {
const blob = bucket.file(filePath);
const blobStream = blob.createWriteStream({
resumable: false,
});
blobStream
.on("error", (err) => reject(err))
.on("finish", () => resolve(true));
blobStream.end(buffer);
});
return new NextResponse(JSON.stringify({ success: true }));
} catch (error) {
return new NextResponse(JSON.stringify(error), { status: 500 });
}
};
- Validate the file, ensuring it’s not a string (which would indicate an error in file retrieval).
- Initialize the Google Cloud Storage (GCS) client with the necessary credentials.
- Convert the file into a buffer using
file.arrayBuffer()
andBuffer.form()
- Wrap the upload logic in a Promise to prevent the function from returning before the upload is complete
- Create a write stream to GCS using
blob.createWriteStream()
. We can’t usebucket.upload()
because the file isn’t written to disk - Listen for
error
andfinish
events to reject or resolve the promise, respectively. - Ends the stream with the file buffer, effectively uploading the file.
- Return the response
Conclusion
That’s all there is to it. I hope this helps save you some time.
My learnings
- Use the formData helper for requests (removes the need for libs like formidable)
- {file}.stream() returns a WHATWG ReadableStream, and this is not compatible with a Node stream
- As a result, the file stream can’t be piped directly to GCS’ Node createWriteStream
If you’ve got any insights or see risks with this approach, feel free to drop me a comment below.