Updating tomalle.com from Remix 2 to React Router 7

Published At: 2025-05-20 17:35:02 UTC

Updating tomalle.com from Remix 2 to React Router 7

After going through the process of setting up a new RR7 app, and with some inspiration from my mum to actually write some stuff here, I of course procrastinated the blogging part to instead do coding. Unfortunately, as we all know, framework major version upgrades combined with the JS ecosystem as a whole meant lots of fun pain. I've catalogued some of the problems and solutions here on the off chance that it can help someone else.

Step 1 - React Router v7

The RR7 docs include the details on upgrading, they are generally quite good, and I do like the new Route type, however, I did have to manually go and make many of the changes that the codemod should have done and also the imports for the generated type would get suggested wrong by PHPStorm (eg import type { Route } from "./+types/my-route"; would get suggested as import type { Route } from "../../app/routes/+types/my-route";). Maybe there is a way to fix that, but I couldn’t come across one.

The Cloudflare specific changes of load-context.ts being moved into the exposed worker fetch initialisation makes a lot more sense. Previously it did all feel a bit magic. The new Vite environment API is also quite nice, the pnpm run dev experience of Cloudflare workers paired with Vite and Remix/RR has improved drastically.

Step 2 - Tailwind v4

The tailwind@4 upgrade was a bit more complicated. A combination of the upgrade utility not picking up classes, combined with my lack of previous interaction with CSS’s @layer functionality (which is where most of the configuration has moved to). Most of this complexity came from getting the tailwind-animate (for shadcn/ui) and @tailwind/typography (to style the blog content, resume and general text content) plugins working. There were quite a few threads of people with the same sort of issues. My solution ended up being a index.css that started like this:

@import "tailwindcss";  
@import "tw-animate-css";  
@plugin "@tailwindcss/typography";

Note the replacement of tailwind-animate with tw-animate-css to enable css first config.

and then configuring the setting for .prose like this:

@layer utilities {  
  .prose {  
    --tw-prose-body: var(--foreground);  
    --tw-prose-headings: var(--foreground);  
    --tw-prose-lead: var(--foreground);  
    --tw-prose-links: var(--muted-foreground);  
    --tw-prose-bold: var(--foreground);  
    --tw-prose-counters: var(--foreground);  
    --tw-prose-bullets: var(--muted-foreground);  
    --tw-prose-hr: var(--foreground);  
    --tw-prose-quotes: var(--foreground);  
    --tw-prose-quote-borders: var(--foreground);  
    --tw-prose-captions: var(--foreground);  
    --tw-prose-code: var(--foreground);  
    --tw-prose-pre-code: var(--foreground);  
    --tw-prose-pre-bg: var(--background);  
    --tw-prose-th-borders: var(--foreground);  
    --tw-prose-td-borders: var(--foreground);  
  }  
}

with the rest of my setup as per the TW@4 docs.

As a sidenote I also removed scss and postcss since tailwind no longer needs the latter for v4 and I'm always looking to reduce dependencies, this did have the unfortunate side effect of no longer being able to import the hljs (syntax highlighting) dynamically insight light and dark mode selectors. In theory I think I can resolve this with some conditionals on the imports but for the meantime I have just copied the css from the theme package.

Step 3 - react-markdown

Ah, the one that got away... As with all things JS it would seem, there was one issue that I couldn't overcome (given I did give up after a few hours, I'm sure there is a solution out there somewhere). No matter what I did, I could not get react-markdown or any other MD -> React package for that matter to work. If I ran it as a server component (importing react-markdown in the route file) I would get a document is not defined error. When importing react-markdown in a client component (load a component <component>.client.tsx) it would work when navigating client site (eg when that component isnt getting SSR'd) but when attempting to SSR that page it would error as well (something about a reference to undefined). I think this is probably something to do with the worker environment react-markdown and many of the other markdown packages I looked into used a bunch of things that looked non-worker-friendly) however it was working before the update so idk... Put it in the too-hard basket for now and just manually encoded the markdown to HTML and then chucked the prose class on it.

Step 4 - Tiptap

While Tiptap v3 is in beta, I was changing enough so decided to stick with v2. This meant that fundamentally nothing changed, but I did take this change to clean up the styles on the admin side of things (creating posts). I know that creating a "CMS" and implementing an editor is so far overkill, and not even the most performant or convenient option, BUT it is very interesting and a cool challenge. Tiptap makes this whole process quite easy, a lot of it "just works" out of the box, some features (for example the paste handler) are behind a paywall but for anything I need there are relatively simple 3rd party implementations (good on them for charging for their work, but I'm not paying $50 dollars a month for my blog). I made a start on implementing the desired image workflow of paste -> encode (with Cloudflare images via a worker binding, if they make this too expensive I can just host FFMPEG in a docker container with a simple API in front) -> upload to R2 and return URL. Ive got this "working" but while in the code I get a response from the R2 PUT, the asset doesn't show up, will figure that out later.

Handle paste (just the initial implementation):

editor.setOptions({  
  editorProps: {  
    handlePaste: () => {  
      void (async () => {  
        const clipboardItems = await navigator.clipboard.read();  
  
        const clipboardItem = clipboardItems[0];  
        const { types } = clipboardItem;  
        const type = types[0];  
        const blob = await clipboardItems[0].getType(type);  
  
        if (allowedImageTypes.includes(type)) {  
          toast("Uploading image...");  
  
          const extension = type.split("/")[1];  
          const formData = new FormData();  
          formData.append("image", new File([blob], `image.${extension}`));  
  
          const uploadedImage = await fetch("/api/transform-image", {  
            method: "POST",  
            body: formData,  
          });  
  
          if (!uploadedImage.ok) {  
            toast.error("Error uploading image");  
            return;          }  
  
          const { fileName } = (await uploadedImage.json()) as {  
            fileName: string;  
          };  
  
          try {  
            editor  
              .chain()  
              .focus()  
              .setImage({  
                src: fileName,  
              })  
              .run();  
          } catch (e) {  
            if (e instanceof Error) {  
              toast.error(e.message);  
            }  
            console.error(e);  
          }  
        }  
      })();  
    },  
  },  
});

And the simplicity that I appreciate from CF workers:

export const action = async ({ context, request }: Route.ActionArgs) => {  
  //AUTH Happens Here
  try {  
    const formData = await request.formData();  
    const file = formData.get("image");  
  
    if (  
      !file ||  
      typeof file === "string" ||  
      typeof file.arrayBuffer !== "function"  
    ) {  
      return new Response("No image file provided", { status: 400 });  
    }  
  
    const fileBuffer = file.stream();  
  
    const imageResponse = (  
      await context.env.IMAGES.input(fileBuffer).output({  
        format: "image/avif",  
        quality: 80,  
      })  
    ).response();  
  
    const fileName = `uploads-${self.crypto.randomUUID()}.avif`;  
  
    const upload = await r2put(context.env.R2, fileName, imageResponse.body, {  
      httpMetadata: {  
        contentType: "image/avif",  
      },  
    });  

	//This is logging that the upload was successful, so why no object R2...
    console.log(upload);  
  
    return new Response(  
      JSON.stringify({  
        fileName: `https://${context.env.R2_HOST}/${fileName}`,  
      }),  
      {  
        status: 200,  
        headers: {  
          "Content-Type": "application/json",  
        },  
      },  
    );  
  } catch (err) {  
    if (err instanceof Error) {  
      console.log("Error Transforming/Uploading", err.message);  
    }  
    console.log("Error Transforming/Uploading", err);  
  }  
};

In other news, new bot kibble (blog post, food for the AI bots) every week is my plan, lets see how that goes. Next week will be about GLaDOS in my home assistant and OPNSense.