├── .github
└── FUNDING.yml
├── .gitignore
├── public
└── favicon.ico
├── remix.env.d.ts
├── .eslintrc.cjs
├── remix.config.js
├── README.md
├── tsconfig.json
├── app
├── entry.client.tsx
├── root.tsx
├── entry.server.tsx
└── routes
│ └── _index.tsx
└── package.json
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: sergiodxa
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | /.cache
4 | /build
5 | /public/img
6 | /public/build
7 | .env
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sergiodxa/remix-demo-file-upload/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/remix.env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import('eslint').Linter.Config} */
2 | module.exports = {
3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"],
4 | };
5 |
--------------------------------------------------------------------------------
/remix.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('@remix-run/dev').AppConfig} */
2 | export default {
3 | ignoredRouteFiles: ["**/.*"],
4 | // appDirectory: "app",
5 | // assetsBuildDirectory: "public/build",
6 | // publicPath: "/build/",
7 | // serverBuildPath: "build/index.js",
8 | };
9 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Remix Demo: File Upload
2 |
3 | A simple demo on how to add a file upload that shows the image being uploaded and later replace it with the actual one.
4 |
5 | ## How to run this demo
6 |
7 | 1. Clone this repo
8 | 2. Run `npm install`
9 | 3. Run `npm run dev`
10 | 4. Open `http://localhost:3000`
11 |
12 | Any file you upload will be stored in the `public/img` folder.
13 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
5 | "isolatedModules": true,
6 | "esModuleInterop": true,
7 | "jsx": "react-jsx",
8 | "moduleResolution": "Bundler",
9 | "resolveJsonModule": true,
10 | "target": "ES2022",
11 | "strict": true,
12 | "allowJs": true,
13 | "forceConsistentCasingInFileNames": true,
14 | "baseUrl": ".",
15 | "paths": {
16 | "~/*": ["./app/*"]
17 | },
18 |
19 | // Remix takes care of building everything in `remix build`.
20 | "noEmit": true
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * By default, Remix will handle hydrating your app on the client for you.
3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
4 | * For more information, see https://remix.run/file-conventions/entry.client
5 | */
6 |
7 | import { RemixBrowser } from "@remix-run/react";
8 | import { startTransition, StrictMode } from "react";
9 | import { hydrateRoot } from "react-dom/client";
10 |
11 | startTransition(() => {
12 | hydrateRoot(
13 | document,
14 |
15 |
16 |
17 | );
18 | });
19 |
--------------------------------------------------------------------------------
/app/root.tsx:
--------------------------------------------------------------------------------
1 | import { cssBundleHref } from "@remix-run/css-bundle";
2 | import type { LinksFunction } from "@remix-run/node";
3 | import {
4 | Links,
5 | LiveReload,
6 | Meta,
7 | Outlet,
8 | Scripts,
9 | ScrollRestoration,
10 | } from "@remix-run/react";
11 |
12 | export const links: LinksFunction = () => [
13 | ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
14 | ];
15 |
16 | export default function App() {
17 | return (
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "remix-demo-file-upload",
3 | "private": true,
4 | "sideEffects": false,
5 | "type": "module",
6 | "scripts": {
7 | "build": "remix build",
8 | "dev": "remix dev --manual",
9 | "start": "remix-serve ./build/index.js",
10 | "typecheck": "tsc"
11 | },
12 | "dependencies": {
13 | "@remix-run/css-bundle": "^2.1.0",
14 | "@remix-run/node": "^2.1.0",
15 | "@remix-run/react": "^2.1.0",
16 | "@remix-run/serve": "^2.1.0",
17 | "isbot": "^3.6.8",
18 | "react": "^18.2.0",
19 | "react-dom": "^18.2.0"
20 | },
21 | "devDependencies": {
22 | "@remix-run/dev": "^2.1.0",
23 | "@remix-run/eslint-config": "^2.1.0",
24 | "@types/react": "^18.2.20",
25 | "@types/react-dom": "^18.2.7",
26 | "eslint": "^8.38.0",
27 | "typescript": "^5.1.6"
28 | },
29 | "engines": {
30 | "node": ">=18.0.0"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * By default, Remix will handle generating the HTTP Response for you.
3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
4 | * For more information, see https://remix.run/file-conventions/entry.server
5 | */
6 |
7 | import { PassThrough } from "node:stream";
8 |
9 | import type { AppLoadContext, EntryContext } from "@remix-run/node";
10 | import { createReadableStreamFromReadable } from "@remix-run/node";
11 | import { RemixServer } from "@remix-run/react";
12 | import isbot from "isbot";
13 | import { renderToPipeableStream } from "react-dom/server";
14 |
15 | const ABORT_DELAY = 5_000;
16 |
17 | export default function handleRequest(
18 | request: Request,
19 | responseStatusCode: number,
20 | responseHeaders: Headers,
21 | remixContext: EntryContext,
22 | loadContext: AppLoadContext
23 | ) {
24 | return isbot(request.headers.get("user-agent"))
25 | ? handleBotRequest(
26 | request,
27 | responseStatusCode,
28 | responseHeaders,
29 | remixContext
30 | )
31 | : handleBrowserRequest(
32 | request,
33 | responseStatusCode,
34 | responseHeaders,
35 | remixContext
36 | );
37 | }
38 |
39 | function handleBotRequest(
40 | request: Request,
41 | responseStatusCode: number,
42 | responseHeaders: Headers,
43 | remixContext: EntryContext
44 | ) {
45 | return new Promise((resolve, reject) => {
46 | let shellRendered = false;
47 | const { pipe, abort } = renderToPipeableStream(
48 | ,
53 | {
54 | onAllReady() {
55 | shellRendered = true;
56 | const body = new PassThrough();
57 | const stream = createReadableStreamFromReadable(body);
58 |
59 | responseHeaders.set("Content-Type", "text/html");
60 |
61 | resolve(
62 | new Response(stream, {
63 | headers: responseHeaders,
64 | status: responseStatusCode,
65 | })
66 | );
67 |
68 | pipe(body);
69 | },
70 | onShellError(error: unknown) {
71 | reject(error);
72 | },
73 | onError(error: unknown) {
74 | responseStatusCode = 500;
75 | // Log streaming rendering errors from inside the shell. Don't log
76 | // errors encountered during initial shell rendering since they'll
77 | // reject and get logged in handleDocumentRequest.
78 | if (shellRendered) {
79 | console.error(error);
80 | }
81 | },
82 | }
83 | );
84 |
85 | setTimeout(abort, ABORT_DELAY);
86 | });
87 | }
88 |
89 | function handleBrowserRequest(
90 | request: Request,
91 | responseStatusCode: number,
92 | responseHeaders: Headers,
93 | remixContext: EntryContext
94 | ) {
95 | return new Promise((resolve, reject) => {
96 | let shellRendered = false;
97 | const { pipe, abort } = renderToPipeableStream(
98 | ,
103 | {
104 | onShellReady() {
105 | shellRendered = true;
106 | const body = new PassThrough();
107 | const stream = createReadableStreamFromReadable(body);
108 |
109 | responseHeaders.set("Content-Type", "text/html");
110 |
111 | resolve(
112 | new Response(stream, {
113 | headers: responseHeaders,
114 | status: responseStatusCode,
115 | })
116 | );
117 |
118 | pipe(body);
119 | },
120 | onShellError(error: unknown) {
121 | reject(error);
122 | },
123 | onError(error: unknown) {
124 | responseStatusCode = 500;
125 | // Log streaming rendering errors from inside the shell. Don't log
126 | // errors encountered during initial shell rendering since they'll
127 | // reject and get logged in handleDocumentRequest.
128 | if (shellRendered) {
129 | console.error(error);
130 | }
131 | },
132 | }
133 | );
134 |
135 | setTimeout(abort, ABORT_DELAY);
136 | });
137 | }
138 |
--------------------------------------------------------------------------------
/app/routes/_index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | json,
3 | unstable_createMemoryUploadHandler,
4 | unstable_parseMultipartFormData,
5 | unstable_createFileUploadHandler,
6 | unstable_composeUploadHandlers,
7 | } from "@remix-run/node";
8 | import type { NodeOnDiskFile, ActionFunctionArgs } from "@remix-run/node";
9 | import { useFetcher } from "@remix-run/react";
10 | import { useEffect, useState } from "react";
11 |
12 | export async function action({ request }: ActionFunctionArgs) {
13 | let formData = await unstable_parseMultipartFormData(
14 | request,
15 | unstable_composeUploadHandlers(
16 | unstable_createFileUploadHandler({
17 | // Limit file upload to images
18 | filter({ contentType }) {
19 | return contentType.includes("image");
20 | },
21 | // Store the images in the public/img folder
22 | directory: "./public/img",
23 | // By default `unstable_createFileUploadHandler` add a number to the file
24 | // names if there's another with the same name, by disabling it we replace
25 | // the old file
26 | avoidFileConflicts: false,
27 | // Use the actual filename as the final filename
28 | file({ filename }) {
29 | return filename;
30 | },
31 | // Limit the max size to 10MB
32 | maxPartSize: 10 * 1024 * 1024,
33 | }),
34 | unstable_createMemoryUploadHandler(),
35 | ),
36 | );
37 |
38 | let files = formData.getAll("file") as NodeOnDiskFile[];
39 | return json({
40 | files: files.map((file) => ({ name: file.name, url: `/img/${file.name}` })),
41 | });
42 | }
43 |
44 | export default function Component() {
45 | let { submit, isUploading, images } = useFileUpload();
46 |
47 | return (
48 |
49 | Upload a file
50 |
51 |
52 | {/* Here we use our boolean to change the label text */}
53 | {isUploading ? Uploading image...
: Select an image
}
54 |
55 | submit(event.currentTarget.files)}
61 | />
62 |
63 |
64 |
65 | {/*
66 | * Here we render the list of images including the ones we're uploading
67 | * and the ones we've already uploaded
68 | */}
69 | {images.map((file) => {
70 | return ;
71 | })}
72 |
73 |
74 | );
75 | }
76 |
77 | function useFileUpload() {
78 | let { submit, data, state, formData } = useFetcher();
79 | let isUploading = state !== "idle";
80 |
81 | let uploadingFiles = formData
82 | ?.getAll("file")
83 | ?.filter((value: unknown): value is File => value instanceof File)
84 | .map((file) => {
85 | let name = file.name;
86 | // This line is important, this will create an Object URL, which is a `blob:` URL string
87 | // We'll need this to render the image in the browser as it's being uploaded
88 | let url = URL.createObjectURL(file);
89 | return { name, url };
90 | });
91 |
92 | let images = (data?.files ?? []).concat(uploadingFiles ?? []);
93 |
94 | return {
95 | submit(files: FileList | null) {
96 | if (!files) return;
97 | let formData = new FormData();
98 | for (let file of files) formData.append("file", file);
99 | submit(formData, { method: "POST", encType: "multipart/form-data" });
100 | },
101 | isUploading,
102 | images,
103 | };
104 | }
105 |
106 | function Image({ name, url }: { name: string; url: string }) {
107 | // Here we store the object URL in a state to keep it between renders
108 | let [objectUrl] = useState(() => {
109 | if (url.startsWith("blob:")) return url;
110 | return undefined;
111 | });
112 |
113 | useEffect(() => {
114 | // If there's an objectUrl but the `url` is not a blob anymore, we revoke it
115 | if (objectUrl && !url.startsWith("blob:")) URL.revokeObjectURL(objectUrl);
116 | }, [objectUrl, url]);
117 |
118 | return (
119 |
130 | );
131 | }
132 |
--------------------------------------------------------------------------------