File uploads with Remix

This article features old apis. Read the latest guide here.

As of writing this there is an official guide on file uploads in Remix but it's basically a placeholder until they iron out some APIs and have time to revisit the guide.

This is my unofficial guide.

<Form />

To use the Remix <Form /> component with files you must use the encType prop and update the method to "post" or "put":

<Form method='post' encType='multipart/form-data'>
    <input type='file' name='my-file' />
    <button>Submit</button>
</Form>

Now, in your route's action you can pull that File out of the form data.

export async function action({request}: ActionFuntionArgs) {
    const formData = await request.formData()
    const file = formData.get('my-file')
    if (!(file instanceof File)) throw new Error('Invalid file')

    // process file

    return {
        message: "File received"
    }
}

The uploaded file(s) will be stored in memory and you'll need to decide what to do with them.

Base64

You could convert the upload into a base64 value:

const formData = await request.formData()
const file = formData.get('my-file')
if (!(file instanceof File)) throw new Error('Invalid file')
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const base64 = buffer.toString('base64')

But most likely you'll want to store them somewhere...

Local file system

Luckily, Remix has a handy utility for storing uploads locally. Here's what that looks like:

import {
    unstable_createFileUploadHandler,
    unstable_parseMultipartFormData
} from '@remix-run/node'

export async function action({request}: ActionFuntionArgs) {
    const uploadHandler = unstable_createFileUploadHandler({
        // where to save the file
        directory: '/app/storage',
        // use the incoming filename instead of creating a new name
        file: (args) => args.filename
    })
    const formData = await unstable_parseMultipartFormData(request, uploadHandler)

    return {
        message: "File received"
    }
}

unstable_parseMultipartFormData

You might have noticed that request.formData() went away and was replaced with this unstable_parseMultipartFormData function. This is a Remix utility for iteratively deciding where uploads go.

  • request.formData() - read everything into memory and then decide what to do with file data
  • unstable_parseMultipartFormData - decide what to do with file data as it's being read

This allows users to upload big files without the application storing those entirely in memory all at once. Instead, you can stream those files into their destinations.

The unstable_parseMultipartFormData function will iterate over the form data keys and if the key contains a file, it will hand that off to be processed by the upload handler.

Gotcha

One "gotcha" with parsing the form data this way is that you don't have access to the other form values because the FormData instance hasn't been created yet. For example, say you had another input in the same form for the user to select a category for the file that determines where that file lives on disk of if you need to do some validation on the provided category.

const formData = await unstable_parseMultipartFormData(request, uploadHandler)
const category = formData.get('category')

// Now I just need to valida....oh wait, my file has already been uploaded...

To get around this, you can use the useSubmit hook and append values as search params to be read in the action.

const submit = useSubmit()

return (
    <Form method="post" onSubmit={(e) => {
        const formData = new FormData(e.currentTarget)
        const params = new URLSearchParams({category: formData.get('category')})
        const options = {
            action: `/app/upload?${params}`,
            method: "post",
            encType: "multipart/form-data"
        }
        submit(formData, options)
    }}>
)

And then in your action:

export async function action({request}: ActionFuntionArgs) {
    const url = new URL(request.url)
    const cateogry = url.searchParams.get('category')

    // continue on to use unstable_parseMultipartFormData()
}

Custom upload handlers

Remix allows you to create your very own upload handlers.

const uploadHandler: UploadHandler = async ({ data, filename }) => {

    // handle the data and filename however you need to

    // return File | string | null | undefined
    return "https://example.com/assets/newly-uploaded-file.png";
}
const formData = await unstable_parseMultipartFormData(request, uploadHandler);

// Remix swaps in whatever you return from your upload handler
const uploadUrl = formData.get('my-file')

Composing upload handlers

Remix also comes with a utility for using multiple upload handlers to handle certain uploads differently. The following is lifted directly from the Remix docs.

  const uploadHandler = unstable_composeUploadHandlers(
    // custom upload handler
    async ({ name, contentType, data, filename }) => {
        if (name !== "img") {
            return undefined;
        }
        const uploadedImage = await uploadImageToCloudinary(data);
        return uploadedImage.secure_url;
    },
    // fallback to memory for everything else
    unstable_createMemoryUploadHandler()
);

const formData = await unstable_parseMultipartFormData(
    request,
    uploadHandler
);

In this example, the first handler only applies to the upload coming from the input with the name of "img":

<input type='file' name='img' />

By returning undefined, Remix will try the next upload handler. In this case, it's the unstable_createMemoryUploadHandler.

unstable_createMemoryUploadHandler

Wait, why is there a unstable_createMemoryUploadHandler? Wouldn't that be the same as request.formData()? Yep, almost. This utility does come with the ability to limit the upload size. It defaults to 3MB.

const uploadHandler = unstable_createMemoryUploadHandler({maxPartSize: 5000000}) // 5MB
const formData = await unstable_parseMultipartFormData(
    request,
    uploadHandler
);
const file = formData.get('my-file')

// up to you to handle now

Upload to S3

Here's an example that illustrates efficiently streaming the upload to S3.

import {
  type ActionFunctionArgs,
  json,
  unstable_parseMultipartFormData,
  type UploadHandler,
} from "@remix-run/node";
import { S3Client } from "@aws-sdk/client-s3";
import { env } from "~/env.server";
import { Upload } from "@aws-sdk/lib-storage";
import { Readable } from "stream";

// you probably wouldn't actually create the s3 client here...
const s3 = new S3Client({
  region: env.AWS_REGION,
  credentials: {
    accessKeyId: env.AWS_ACCESS_KEY_ID,
    secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
  },
});

export async function action({ request }: ActionFunctionArgs) {
  const uploadHandler: UploadHandler = async ({
    data,
    filename,
  }) => {
    const readableStream = Readable.from(data);
    const upload = new Upload({
      client: s3,
      params: {
        Bucket: env.AWS_S3_BUCKET,
        Key: filename,
        Body: readableStream,
      },
    });
    await upload.done();
    return filename;
  };

  await unstable_parseMultipartFormData(request, uploadHandler);

  return json({
    success: true,
  });
}

Hope this helps!

Categories: Remix