├── .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 | 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 | {name} 130 | ); 131 | } 132 | --------------------------------------------------------------------------------