Build a newsletter form with Remix & third party scripts in Fly.io

I'm starting my own newsletter. I've been pondering with the idea for a while now and the release of Remix gave me the perfect excuse. To accomodate the styling I used Tailwind CSS since Remix is still very new and its styling options are somewhat limited. Afterall it was time to find out what all the fuss was about.

Before reading any further though, please do yourself a favor and watch this video where Ryan Florence gives a masterclass on building an accessibility-friendly newsletter in Remix. Now that you did, let's talk about my experience.

Setting up Remix with Tailwind CSS was pretty straight-forward so I won't dive into any details here. The docs are a great place to go if you need any help. The plan was to reuse functionality from Ryan's video and port it over to this very website. Soon enough though, I bumped over the first issue: I needed to have the subscription form in a prominent place under the root route and not under /dashboard. However here's what's happening when Form POSTs to root:

See the /?index in the address bar right there? I managed to track it down to this GH issue:

"index" routes are special and require a ?index param to post too ... layout and index routes have the same path and to distinguish what what to post too, the ?index param is used

Avatar of Ebbey
Jacob EbeyRemix.run

Unfortunately when it comes to root routes, there doesn't seem to be a way around it. Eventually I reached out to fetcher.Form which was mostly plug n' play: Just replace every Form tag with fetcher.Form and things will work out of the box. Be careful though, cause state will now be induced from fetcher.submission instead of useActionData().

Original state handling

const actionData = useActionData();
const transition = useTransition();
const state: "idle" | "success" | "error" | "submitting" = transition.submission
  ? "submitting"
  : actionData?.subscription
  ? "success"
  : actionData?.error
  ? "error"
  : "idle";
          
fetcher.Form state handling

const state = fetcher.submission
  ? 'submitting'
  : fetcher?.data?.subscription
  ? 'success'
  : fetcher?.data?.error
  ? 'error'
  : 'idle';
          

The rest of the logic stays exactly the same as everything works off of state.

What about external scripts?

I'm glad you asked. 2 words: Not great.

Turns out that there's currently no Remix "blessed" way of loading third party scripts. Look at the code snippets above: This is just me using the excellent prism.js library. But you might be wondering how I did load the library... Unfortunately the answer is, I used good old guerilla JS tricks. Allow me to explain: The spirit of Remix is to load as little as possible, as late as possible. Or something along these lines. However there doesn't seem to be a canonical way for doing so when it comes to loading external scripts. Unlike the beauty of the links function, scripts have to be loaded manually on component mount:


const [loaded, setLoaded] = useState(false);

useEffect(() => {
  const scriptTag = document.createElement('script');
  scriptTag.type = 'text/javascript';
  scriptTag.setAttribute("id", "prism-script");
  scriptTag.async = 'async';
  scriptTag.src = 'https://cdn.jsdelivr.net/npm/prismjs@1.26.0/prism.min.js';
  scriptTag.addEventListener('load', () => setLoaded(true));
  document.body.appendChild(scriptTag);

  // When navigating away, don't forget to remove the script
  return () => {
    let scriptToRemove = document.getElementById("prism-script");
    if (!scriptToRemove) return;

    scriptToRemove.remove();
  };
}, []);

// When the script loads, activate it
useEffect(() => {
  if (!loaded) return;

  Prism.highlightAll();
}, [loaded]);
            

This worked fine and did the job but I wish there was a better way. The fine folks over at Remix have done an excellent job and I can see why a scripts function may not be that trivial to implement. Just think about all the cases that can go wrong: Initialization, asynchronicity etc. For the time being though, looking at the way people load analytics doesn't exactly get my hopes up. Hopefully this gets addressed soon by the core team.

What about deployment?

Since Fly.io seems to have the blessings of the Remix team, I decided to get along. After creating my account, I literally had to run the following 4 commands:
Well, I lied. There's a more to it. Deployment was initially failing. The fix though, was pretty easy: I just had to do this. Problem is there weren't any pointers on that and I had to google my way to it.

That last issue turned out a bit trickier: I was about to use an SSL certificate (comes for free after all!). However even though the certificate was issued fined, any user visiting my website without explicitly typing https://, would get served the unencrypted version of it. What I needed here was automatic redirection to https. Fly has a vague blog post about it and assumes you are running on an express server. But I don't. Anyway, after a bit of spelunking in discord, I found a post by the legendary Kent C. Dodds pointing to the solution he used on his own website. Even though he was also using Express js, I took the spirit of it and rolled my own solution within entry.server.jsx


const requestURL = new URL(request.url)
const proto = request.headers.get('X-Forwarded-Proto')
const host = request.headers.get('X-Forwarded-Host') ?? request.headers.get('host')

if (proto === 'http') {
  responseStatusCode = 301
  responseHeaders.set('X-Forwarded-Proto', 'https')
  responseHeaders.set('location', 'https://' + host + requestURL.pathname)
}

return new Response("<!DOCTYPE html>" + markup, {
  status: responseStatusCode,
  headers: responseHeaders
});
            

I understand that this may be suboptimal since we are creating a new URL object on every request, but you know what? It works. And that's what matters... at least for now. Hopefully Fly will automate it in the future by letting us set a simple flag in fly.toml and be done with it.

That's it! Thanks for reading! Remember to subscribe if you liked it! ❤️