File upload to Google Cloud Storage (GCS) in Next.js 14 API Route Handler

Jordon McKoy
3 min readFeb 8, 2024

--

Photo by Annie Spratt on Unsplash

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>
);
}
  1. Validate the presence of audioBlob, the Blob audio data.
  2. Create FormData and append the audioBlob with a dynamically generated filename based on the current timestamp, ensuring uniqueness. In the app code, a user ID is also added.
  3. Configure and send a POST request to the /api/upload endpoint using the fetch API, passing the FormData 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 });
}
};
  1. Validate the file, ensuring it’s not a string (which would indicate an error in file retrieval).
  2. Initialize the Google Cloud Storage (GCS) client with the necessary credentials.
  3. Convert the file into a buffer using file.arrayBuffer() and Buffer.form()
  4. Wrap the upload logic in a Promise to prevent the function from returning before the upload is complete
  5. Create a write stream to GCS using blob.createWriteStream(). We can’t use bucket.upload() because the file isn’t written to disk
  6. Listen for error and finish events to reject or resolve the promise, respectively.
  7. Ends the stream with the file buffer, effectively uploading the file.
  8. 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.

--

--

Jordon McKoy
Jordon McKoy

Written by Jordon McKoy

Founder and Software Consultant with a love for building B2C and B2B SaaS products.

No responses yet