In one of my projects, I was building a feature that allowed users to download a file after a specific interaction, like clicking a button to generate a report. This scenario is quite common in web applications—for instance, exporting table data as a CSV file or downloading a PDF report or invoice. It’s likely that you’ll need to implement file downloads in your app at some point too.
First, let’s take a look at the simplest way to handle file downloads in Remix with Resource Routes.
Resource Routes in Remix
Resource routes in Remix are simple way to send files to the browser in response to a request. While your regular routes are used to return UI Views (and JSON data from loaders), resource routes can be used to return any kind of data, including files like images, PDFs, CSVs, etc or any other type of response.
The route shouldn’t export any default component, just the loader (or action) function that returns a Response
object.
Here is a simple example of a resource route that returns a PDF file:
// src/routes/reports.download.ts
export async function loader({ request }) {
const pdfData = await generatePdf(); // your function that generates the actual PDF data
return new Response(pdfData, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": "attachment; filename=report.pdf",
},
});
}
And then, in your UI components, you can link to the route to trigger the download:
// src/components/DownloadLink.tsx
import { Link } from "@remix-run/react";
export const DownloadLink = () => {
return (
<Link to="/reports/download" download reloadDocument>
Download PDF
</Link>
);
};
There is a small caveat with this approach, as Remix docs mention:
There’s a subtle detail to be aware of when linking to resource routes. You need to link to it with
<Link reloadDocument>
or a plain<a href>
. If you link to it with a normal<Link to="pdf">
without reloadDocument, then the resource route will be treated as a UI route. Remix will try to get the data with fetch and render the component.
This works well for simple file downloads, but for cases that require more complex interactions, like displaying a loading indicator, you may want to use a more flexible approach.
Sending file downloads with useFetcher hook
The useFetcher
hook is a part of the Remix framework and it allows you to send requests to your own routes from your components. With this approach, we can fetch the file data from a resource route asynchronously and then trigger the download in the browser.
Let’s change our route to return the PDF data as JSON:
// src/routes/reports.download.ts
export async function loader({ request }) {
const pdfData = await generatePdf(); // your function that generates the actual PDF data
return json({
fileData: pdfData.toString("base64"),
fileName: "report.pdf",
fileType: "application/pdf",
});
}
The action will now return the JSON data with the file data & some extra metadata like file name and type. This will be handy in the client code when we want to trigger the download.
Now, let’s create a component that will handle the download.
We’ll use the useFetcher
hook to fetch the file data and then trigger the download in the browser.
// src/components/DownloadButton.tsx
import { useEffect } from "react";
import { useFetcher } from "@remix-run/react";
type DownloadLinkData = {
fileData: string;
fileType: string;
fileName: string;
};
// Helper function to convert base64 string to Uint8Array
const base64ToUint8Array = (base64: string): Uint8Array => {
const binaryString = atob(base64);
const len = binaryString.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
};
export const DownloadButton = () => {
const fetcher = useFetcher<DownloadLinkData>();
const handleDownload = () => {
if (fetcher.state !== "idle") return;
fetcher.load("/reports/download");
};
useEffect(() => {
// skip effect if the fetcher is not ready (or we don't have the data)
if (!fetcher.data || fetcher.state !== "idle") {
return;
}
// get the file data from the fetcher
// we can use type and name to help the browser handle the download
const { fileData, fileType, fileName } = fetcher.data;
// dynamically create a blob with the file data
const byteData = base64ToUint8Array(fileData);
const blob = new Blob([byteData], { type: fileType });
// create a URL for the blob and trigger the download link
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
}, [fetcher.state, fetcher.data]);
return (
<button onClick={handleDownload}>
{fetcher.state !== "idle" ? "Generating report..." : "Generate report"}
</button>
);
};
Using Blob
and URL.createObjectURL
, we can create a temporary URL for the file data and start the download directly in the browser.
The main advantage of this approach is that we can handle the download process in a more flexible way, for example by showing a loading indicator while the file is being generated on the server.
We also had to add a helper function base64ToUint8Array
to convert the base64 string to a Uint8Array
that can be used to create a Blob
.
This is necessary if you want to send binary data (like PDFs) from the server to the client.
If you deal with text data, you can skip this step and pass the data directly to the Blob
constructor.
This is obviously a simplified example, but it should give you a good starting point for handling file downloads in Remix. You can easily extract this logic into a custom hook or a separate component to reuse it across your application.
For example, a custom hook might look like this:
export function useDownloadFetcher() {
const fetcher = useFetcher();
useEffect(() => {
if (!fetcher.data || fetcher.state !== "idle") {
return;
}
// dynamically create download link
const link = window.URL.createObjectURL(new Blob([fetcher.data.fileData], {
type: fetcher.data.fileType,
}););
// create a temporary anchor element
const a = document.createElement("a");
a.href = link;
a.download = fetcher.data.fileName;
a.click();
}, [fetcher.data, fetcher.state]);
return fetcher;
}
In this way, you can easily reuse the download logic in multiple components, while keeping the code clean and maintainable, with consistent and familiar useFetcher
API.
Hope this little article will help you to deal with file downloads in your Remix app. For full example code, you can check out the GitHub repository.
If you have any questions or suggestions, don’t hesitate to get in touch with me and share your thoughts.
Happy coding!