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:

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

Got suggestions? Comment on the gist.