Uploading an image with Next.js (React + Express) to Blazeback B2 (AWS S3 alternative)
After uploading a bunch of (unintentionally) glitched pictures of a spider, I finally figured it out.
Key takeaways if you’re already familiar with Next.js:
- Leverage Streams so you don’t lock up your server when sending it a large file. Streaming is disabled by default in Next.js.
- To enable streaming, disable body parsing (
{ bodyParser: false }
) in your API route. - Send the
File
from your<input type=”file” />
directly to the server (i.e. don’t use form data, don't send the base64 data URI.) - Reconstruct the file stream to a
File
buffer in your API route handler and so something with it (like upload it to B2.)
If you’re not familiar with Next.js, you can think of it as a React.js client + Express.js API backend packaged together nicely with the magic of zero-config. It’s great for when you’re working with services that need to be called on a server to prevent secrets from leaking into the browser.
I’m using axios for HTTP requests, but you can use Fetch
, etc.
Blazeback B2 is an AWS S3-y cloud storage service. I’m trying it out because it has a free tier and I’m working on reducing AMZN’s presence in my life.
All right, let’s do it.
Set up your environment variables
I added my Backblaze application ID and key to my dotenv file:
BACKBLAZE_BUCKET_ID='YOUR_BACKBLAZE_BUCKET_ID'
BACKBLAZE_KEY_ID='YOUR_BACKBLAZE_KEY_ID'
BACKBLAZE_APP_KEY='YOUR_BACKBLAZE_APP_KEY'
Next.js will automatically load these into your server environment for you and they’ll be hidden from your React client.
Add the upload
API route
I’m naming mine media/upload
. Create an upload.js
file under pages/api/media
:
import B2 from 'backblaze-b2';
const uploadHandler = async (req, res) => {
res.status(200).json({});
};
export const config = {
api: {
bodyParser: false,
},
};
export default uploadHandler;
Note that besides the very minimal handler, we're exporting a config
object. This custom config tells Next.js to skip the body parsing middleware, which is enabled by default.
Now, we want to actually consume the stream and combine the chunks into a thing we can upload to Blazeback B2.
const file = await new Promise((resolve) => {
const chunks = [];
req.on('readable', () => {
let chunk;
while (null !== (chunk = req.read())) {
chunks.push(chunk);
}
});
req.on('end', () => {
resolve(Buffer.concat(chunks));
});
});
Now, to send it to B2:
const b2 = new B2({
applicationKeyId: process.env.BACKBLAZE_KEY_ID,
applicationKey: process.env.BACKBLAZE_APP_KEY,
});
const { data: authData } = await b2.authorize();
const { data: uploadData } = await b2.getUploadUrl({
bucketId: process.env.BACKBLAZE_BUCKET_ID,
});
// we'll touch on the filename in the React section
const reqFileName = req.headers['x-filename'];
const { data } = await b2.uploadFile({
uploadUrl: uploadData.uploadUrl,
uploadAuthToken: uploadData.authorizationToken,
data: file,
fileName,
});
// do something with the response data from b2
Here's a full example of api/media/upload.js, including how you might set your API JSON response.
Create an ImageUpload
React component
My example supports PNG and JPEG files, handles only one file at a time, and shows the user a preview of the image before it's uploaded. In components/ImageUpload.js
:
import axios from 'axios';
import { useState } from 'react';
export default function ImageUpload() {
const [previewFile, setPreviewFile] = useState();
const handleChange = (e) => {
// TODO
};
const upload = async () => {
// TODO
};
return (
<div>
<div>
{previewFile && <img src={previewFile.base64} />}
</div>
<input
type="file"
id="my-image-id"
name="my-image-id"
onChange={handleChange}
accept="image/png, image/jpeg"
/>
<button onClick={upload}>Submit</button>
</div>
);
}
Where I originally buffer-f'd up was that I was trying to work with a base64 string in both React and on the server. My browser froze, my server froze, my fingers froze because I was too obsessed to get up and grab a sweater.
The data URL should only be used to preview your file in the browser. You can generate one every time the user selects a new file, in handleChange
:
const handleChange = (e) => {
const files = e.target.files;
const file = files[files.length - 1];
if (file) {
const fileReader = new FileReader();
fileReader.readAsDataURL(file);
fileReader.addEventListener('load', () => {
setPreviewFile({
file,
base64: fileReader.result,
});
});
} else {
setPreviewFile(null);
}
};
When you're ready to upload the file, just send the File instance that came back from your <input />
:
const upload = async () => {
const { file } = previewFile;
const fileExt = file.name.substring(file.name.lastIndexOf('.') + 1);
const fileName = `image.${fileExt}`;
const { data } = await axios.post(`/api/media/upload`, file, {
headers: {
'content-type': file.type,
// pass new file name to API. See "improvements"
'x-filename': fileName,
},
});
// do something with the response data
};
Here's a full example of ImageUpload.
Room for improvement
- I can recall maybe 3 other times in my dev life that I had to interact with Node Streams directly. Let me know if there's a better way to get a buffer out of the stream.
- If there's a better way to handle renaming the file from React than an
x-
header, I'm also all ears.
Got suggestions? Comment on the gist.