How toDownload a file from a React Router route

Let's say you need to dynamically generate a file, like an XML, CSV or PDF, how could you trigger a download?

There are two ways to do it

  1. The simplest way using a loader
  2. The not so simple way using an action

Loader Function

Many want to trigger the download using a button that submits a form because it feels correct.

But if you think about it, downloading a file implies the user is accessing the file, that's more like a GET than a POST, so using a loader function may make more sense.

Let's create a route with a loader that returns an XML.

app/routes/download.ts
import { xml } from "remix-utils/responses"; export async function loader() { let data = await getData(); let body = await generateXML(data); return xml(body); }

Now you can link to this file using a anchor tag.

<a href="/download" download="file.xml">Download file</a>

That's it, when the user clicks the anchor (which could be styled to look like a button) the browser will trigger a GET request for that file.

The download part is key here, as it tells the browser to don't try to open the file content, e.g. when you open PDF, and instead always download it, the value is the file name.

Action Function

There are some cases where you may want to use an action function. Imagine you want to ask the user some information and then send that information to the server in a POST, but create a file as a result.

Here the solution resolves around creating the file, storing it somewhere and return the URL to the browser.

One solution combines a POST with the loader from before. First we need an action that creates and stores the file, and give us the URL.

app/routes/create.tsx
export async function action({ request }: Route.ActionArgs) { let data = await request.formData().then(validate); let file = await generateXML(data); await storage.put(file.name, file); return { href: `/download?name=${encodeURIComponent(file.name)}`, download: file.name, }; } export default function Component({ actionData }: Route.ComponentProps) { return ( <> <Form method="POST"> <input type="text" name="name" /> <button type="submit">Create file</button> </Form> {actionData ? ( <a href={actionData.path} download={actionData.name}> Download file </a> ) : null} </> ); }

This implies the user does two clicks, one to submit the form and another to download the file, which may not be ideal. We also need to update the loader to get the file from the storage instead of generating it, and use the query string to get the file name.

app/routes/download.ts
import { storage } from "~/storage"; export async function loader({ request }: Route.LoaderArgs) { let url = new URL(request.url); let name = url.searchParams.get("name"); if (!name) return new Response(null, { status: 400 }); let file = await storage.get(name); if (!file) return new Response(null, { status: 404 }); return new Response(file); }

Another solution is to use a clientAction to trigger the download after the form is submitted.

app/routes/create.tsx
export async function clientAction({ serverAction }: Route.ClientActionArgs) { let { href, download } = await serverAction(); let anchor = document.createElement("a"); anchor.href = href; anchor.download = download; anchor.click(); return null; }

This way the user only needs to submit the form and get the file back in the response.

The no-JS way

Finally, there's a way to do it without JavaScript, adding reloadDocument to the <Form> component.

app/routes/create.tsx
export default function Component() { return ( <Form method="POST" reloadDocument> <input type="text" name="name" /> <button type="submit">Create file</button> </Form> ); }

This way the user submits the form and the app will not use JS to submit the request but instead let the browser do it. If the server returns a file, the browser will download it.

app/routes/create.tsx
export async function action({ request }: Route.ActionArgs) { let data = await request.formData().then(validate); let file = await generateXML(data); return new Response(file, { headers: { "Content-Disposition": `attachment; filename="${file.name}"`, }, }); }

This way the user only needs to submit the form and get the file back in the response.