Maybe don't navigate(-1) in Remix/React Router

Remix/React Router 7 has a handy hook for navigating without the user interacting. It covers use cases like:

  • Navigating based on complex logic
  • Navigating to logout after inactivity
  • Implementing "back" links

You can give it a route path:

const navigate = useNavigate()

const goSomewhere = () => navigate('/some/place/in/your/app')

Or you can give it an integer to navigate the user's history stack:

const goBack = () => navigate(-1) // go the previous browser location

At first glance, this seems pretty handy for implementing a "Back" link. For example, if you have a list of todos and have the ability for the user to navigate to a single todo. You might want to have a convenient "Back" link to direct the user back to the list of todos. This is especially useful if you have multiple ways to navigate to a single todo:

  • View all todos -> click on a single todo
  • View users todos -> click on a single todo
  • View todos of a certain category -> click on a single todo

"Back" can mean different things in the single todo route so you wouldn't want to just hardcode a link:

<Link to='/todos'>Back</Link>

This would always assume you navigated from a the same place, the /todos route. No bueno. You could, however, slap an event handler on it and make use of navigate(-1):

<Link
  to='/todos'
  onClick={(e) => {
    e.preventDefault()
    navigate(-1)
  }}
>
 Back
</Link>

This seems better but there's another problem...

Browser context

The problem with navigate(-1) is that it isn't limited to your app's history stack. It's the browser tab's history stack. The Remix docs give you some words of caution about this behavior:

Note that this may send you out of your application since the history stack of the browser isn't scoped to just your application.

I can't think of any web application that I use where I want that behavior. I'm of the mind that a "Back" link in a web application should be contained to the application. I don't want to click a "Back" link in an app and get bounced out of the app.

Let's make it better

Remix/React Router 7 gives us a handy way pass data during a navigation via the state property of the <Link> component.

<Link to={`/todos/${todoId}`} state={{back: '/todos'}}>Todos</Link>

And then if the user clicks on that <Link>, in the todos.$id route component you can read that state:

const location = useLocation()
let back = location.state?.back // '/todos'

We just need to make use of that in a "Back" link:

<Link
  to='/todos'
  onClick={e => {
    if (back) {
      e.preventDefault()
      navigate(back)
    }
  }}
>
  Back
</Link>

Now, if the user click on a link with back state defined, we'll use that. Otherwise, we'll let the <Link> navigate to to. Nice.

Let's make it easy

We can define some custom hooks to reuse some of this logic and make things a bit easier.

First, we'll define a simple hook to return the current route url with search params to use as the back state value.

function useCurrentURL() {
  let location = useLocation()
  return location.pathname + location.search
}

Using it would look like something like that.

const currentURL = useCurrentURL()

// ...

<Link to={`/todos/${todoId}`} state={{back: currentURL}}>Back</Link>

Then, we can create a hook to handle this state.

import { type LinkProps, useLocation, useNavigate } from '@remix-run/react'

export function useBackNavigation() {
  let navigate = useNavigate()
  let location = useLocation()

  let handleClick: LinkProps['onClick'] = (e) => {
    let back = location.state?.back
    if (back) {
      e.preventDefault()
      navigate(back)
    }
  }
  return handleClick
}

Using it would look something like this.

const handleBack = useBackNavigation()

// ...

<Link to='/todos' onClick={handleBack}>Back</Link>

Much better. Now we can navigate back based on the presence of a back navigation state value, otherwise we'll fall back to the default link's location. 🎉

Alright, that was a pretty lengthy post about "Back" links but hey, we sweat the small stuff 'round here 🍻

Failure to Launch (A Cautionary Shopify App Tale)

I get inspired by people like Pieter Levels Ruben Gamez, DHH, and Rob Walling, among many others. I recently read Rob Walling's The Stair Step Method of Bootstrapping and thought I'd give that a shot with building a Shopify plugin. I don't know the first thing about effective marketing. I don't have a huge twitter audience or mailing list. The built-in marketing from the Shopify app store was really appealing to me and my lack of marketing skills.

The Idea

Most Shopify product pages have a gallery of images for each product. A set of photos for the same product. Shopify will use the first of those images for the Open Graph images. These photos are also usually portrait oriented(taller than they are wide). This tends to result in the images getting cropped in funky ways leave you with social impressions that leave a lot to be desired. Here's an example.

What you see on social media:

And the actual product:

I thought this would be an interesting problem to tackle so I began to build an app the would generate branded OG images for each product automatically that actually showcased the product in a much more flattering way.

The development

I was really attracted to Shopify because it meant I could use Remix, the react framework I most comfortable and productive with.

The Remix aspect was great. Learning the Shopify ecosystem and sdk's was like drinking from a disjointed fire hose though. I encountered several really confusing things:

  • webhooks
    • subscribing via yaml and/or the shopifyApp remix utility
    • at the time debugging payloads was a headache. It looks like that's gotten better.
    • unsubscribing
      • I would get development emails about webhook subscriptions failing but never knew how to unsubscribe those.
  • apis
    • some things are in the REST api
    • other things are in the GraphQL api
  • development via tunnel
    • sometimes this would just fail to launch so I'd need to restart it randomly
    • you can't develop offline because it gets rendered into the Shopify admin site
    • when you start the app in development a new tunnel is created and this automatically updates your app's configuration to point to it. You have to remember to manually switch this over to your production url or else you're at risk of the reviewer using your development tunnel
  • billing
    • you have to implement your own UI to change plans even though you're supposed to define them in your app config and in the listing
    • billing.request()'s returnUrl was not documented at all. This looks like it's been updated in the docs.
  • the authenticate. vs unauthenticated api's and when to use each

It's probably just the nature of Shopify's ecosystem having grown to be so big, but I found myself bouncing around all over completely different sets of docs.

Injecting meta tags

By default, in the themes I came across there is a meta-tags.liquid snippet that renders the meta tags for og:image, title, description, etc. This gets used in the main theme.liquid file.

In my app's onboarding, I was instructing the user to edit their meta-tags.liquid file to use OG images from my app rather than what's used by default, the shopify cdn.

I found no other way to programmatically update the OG images. Shopify doesn't have a means for users, or developers, to assign specific OG images per product which was very surprising to me. You can set a custom OG image but the same image would be used for every link to your store. Oh well, I'll just settle on instructing the user how to update their theme file I thought... (foreboding music begins to play)

Getting ready

Finally, after spending several evenings getting an app together I was ready to submit the app for review. This involved putting together an app listing page. On the surface that doesn't sound like a big task but when you're lacking in the marketing department it can be tough coming up with app visuals, blurbs, feature lists, logos, app icons, etc. There's a lot more to it than I initially anticipated.

App review

A few days later after I submitted the app I received my first kickback from Shopify which I expected. There's a lot of requirements, not just in the app listing but the app itself. They have an actual person review the listing and attempt to use your app in a demo store.

There were some easy to fix app listing issues I tackled and some billing items that I needed to flesh out more. No problem. The biggest issue was the reviewer telling me that instructing the user to edit theme files to enable my app is not a good user experience and to use "app embed blocks". I hadn't come across them yet and it looked like exactly what I needed. Sweet.

I quickly implemented an app embed block that would inject markup into the <head>. This markup would contain the meta tags for the OG images but there was a problem... When using app embed blocks, your app's markup gets appended to the existing content in the <head>. It ends up looking like this

<head>
    <meta charset="utf-8">

    <!-- BEGIN rendering `meta-tags.liquid` -->
    <meta property="og:image" content="https://cdn.shopify.com/s/files/1/2341/3995/files/some-product-image.jpg?v=1720565083" />
    <meta property="description" content="blah blah blah">
    <!-- more stuff... -->
    <!-- END rendering `meta-tags.liquid` -->

    <!-- BEGIN app block: shopify://apps/your-apps-name/blocks/your_block_name/e512386f-5389-4282-aadb-21c442089ea -->
    <meta property="og:image" content="https://your-app-domain.com/images/product-123" />
    <meta property="description" content="blah blah blah">
    <!-- END app block -->
</head>

The show stopping problem is that social sites only use the first og:image that's rendered and if you don't stop the meta-tags.liquid file from doing that then that's what's going to be used. The app embed block is going to be worthless unless I can prevent Shopify's theme from rendering the og:image tag.

So armed with that knowledge, I updated the onboarding to comment out a single line from the meta-tags.liquid file. This would allow my app embed block's og:image tag to actually be used since it would be the only one rendered in the markup. My new onboarding instructions and image:

  1. Comment out the line
  2. Save the theme file

I deployed my changes and then sent off a reply explaining everything I fixed and how I was now using an app embed block like he suggested but I still had to instruct the user to comment out a line in one of their theme files.

I received a reply and it was at this point that I knew this project was headed for the bin. The reviewer turned into a parrot who kept repeating the same thing during a series of back and forth emails: I needed to not instruct the user to edit theme code and instead accomplish it via app embed code. I repeatedly attempted to explain my dilemma and asked for guidance but was simply told to use the help forum and that reviewers are not responsible for help with code but that I could apply to be able to use the Assets API which allows apps to automatically update theme files. That went nowhere. The back and forth with the reviewer became almost like talking to AI chatbot who kept repeating the same recommendation of using app embed blocks and not instructing the user to edit a theme file even though I've repeatedly explained why that was insufficient.

Even more frustrating is the fact that there are apps in the app store that provide a means to update OG images. When I brought this up to the reviewer I was simply told that the rules have changed. That's a pretty amazing moat for those older apps...

Lessons learned

It's really easy to get incredibly frustrated by this situation but I have to remind myself that no matter what new thing I'm trying, I'm always learning and adding to my toolkit.

  • I've checked another idea off the list of things to try. I'm a doer, not a talker.
  • I now have some familiarity with the Shopify development ecosystem.
  • I've given my marketing muscle a workout when putting together the listing page.
  • I've experienced first-hand why should launch ASAP and fail fast so you can move on to the next thing.
  • I've gained an even deeper respect for indie developers who are actually to make this work.
  • I've learned about MX Route(not affiliated) and now have a lifetime email service for any future app ideas I want to attempt to make work. I've eased that inevitable friction a bit.

If by the off chance someone from Shopify reads this, I would love to be part of a pilot program that allows developers to update OG images per product when that get's implemented. 😀 Or, if anyone knows of someway to do this that I somehow missed, I would love to chat. Reach out to me here, on 𝕏, or Bluesky!

Dynamic OG Images With Remix

I have a Remix hobby project I'm hacking on and thought it would be cool to generate dynamic open graph images for. (The preview images you see for links shared on social media.)

I had heard of the Satori package that can generate SVG's from HTML and CSS. This lead me to the @vercel/og package. I was initially pretty jealous because I just assumed it wouldn't work with Remix, and it looked pretty sweet.

  • Uses JSX
  • Supports Tailwind (not every css class but enough for me)
  • Supports dynamic images
  • Supports loading fonts

The post I came across referenced Vercel's "edge" pretty heavily and my Remix app is just running in a single docker container on a single Hetzner VPS. I decided to give it a shot and was pleasantly surprised that it worked just fine in a Remix loader. 🎉

What I'm generating

My app allows people to create lists of books. Each book can have a cover image assigned to it. I wanted the OG image to contain the title of the list, the user's username with their profile picture, and the first 6 book covers but have the covers slightly blurred to provide some click bait. Whoever sees the OG image will have to click the link to see the un-blurred books. I know. Shame on me.

Here's an example:

(Disclaimer: Ryan Holiday doesn't actually use the app 😅)

The problem

I quickly discovered that since there are so many images being incorporated, it was taking around ~4 seconds to generate the image on my 3 vCPU / 4 GB Cloud VPS. Sites like facebook and twitter would make a request to get the image, give up(there'd be no preview image in the social media post), and then I'd still see subsequent requests to fetch it again in the server logs immediately after. Multiple http requests would be generating the same image at once which would significantly impact the overall performance of the application. That wasn't going to work.

The solution

The solution was rather simple. Instead of generating the images on the fly, I now generate the image beforehand, store it(R2), and then serve that from my remix loader. Luckily, I have a pretty good event to latch onto for this. In the app, all lists are unpublished initially. The user can publish them whenever they're ready. It was in that action it made sense for me to kick off the OG image generation.

The tradeoffs

The major tradeoff with this approach and how I have my app setup is that the image can easily be outdated. For example, if they delete a book from the list or reorder them after they've already published it. Not a huge problem but something to be aware of. This is for social media images, after all. I could figure out a way to prevent this but the payoff isn't there. I'd rather focus on more interesting and valuable problems to solve right now.

Hiccups along the way

I noticed the tailwind blur classes were not supported but that was easily resolved by adding style tags to the images:

style={{ filter: "blur(5px)"}}

Fonts were also a little tricky. I am just trying to use Arial and Georgia here. I ended up having to download the ttf files from my mac's Font Book by right clicking and selecting Show in Finder. Then copy/pasting the Arial.ttf into my remix app's public folder.

Once it was there, I can fetch it in my loader and incorporate it into the ImageResponse.

I'm pretty sure there's an easier, more efficient way to do this but it's what I came up with that worked.

Here's the final result: