Trying something new: NextJS + Supabase

Note: This is a semi-technical post, best suited for techies or tech managers, about the web architecture behind this website, or, "How I got my site to be zero-maintenance, super fast and infinitely scalable"

In early 2020 I rebuilt my old Jekyll site as a Rails + React, which I wrote about previously, here.

Overall it was a success, and I liked my new site a million times more than the old one, but there were a number of things that didn't quite fit right, which I wrote about toward the end of that post.

  • I was using more and more of Bootstrap's utility classes, but still fighting against their built-in design language.
  • The code base still relied on React's class-based components, and not in a particularly elegant way -- would be better to rewrite using Hooks.
  • The Rails back-end was doing both more and less than I would have wanted -- for this site I don't really need much fanciness in Models and Controllers, but I would have loved more built-in support for Auth and Storage rather than feeling a bit like I had to write them both from scratch.

Step 1: Rebuilding "client" app (the React front-end)

My intent when I started off on this rewrite was always to be experimental and very light-weight. In my professional work I tend to focus heavily on the simplicity of the architecture, so that a small team of usually general-purpose devs can maintain a large-scale app without needing a bunch of specialists to keep it running, stable and secure. For example, I'd go with Heroku to avoid server admin or complex EC2 configuration; I'd put as much as possible behind a Cloudflare reverse-proxy rather than configuring and maintaining an NGINX server, etc.

So with the limited time I have to devote to personal projects, I tend to chase the same kinds of requirements for scalability and simplicity, even though the code I'm writing here is quite simple by comparison.

A tweet that reads: "My very big brain 200 IQ method for making programming easier is I only build easy things. Try it out; it probably won't work for you but if it does its sweet and you can combine with other methods like using functions or whatever"

So I figured I would keep the Rails app in place, and rebuild the front end on some more framework-y react-type setup. Almost on a whim one weekend I tried prototyping the front end with Svelte, and it was surprisingly easy to get started!

Svelte is not a React framework, and they claim to have completely reimagined the way a reactive application should work – there's no shadow DOM; now there's a compiler that examines all the components and state changes in your application and compiles the vanilla JavaScript needed to directly update the DOM in response to changes. It's kind of incredible!

But actually I wasn't looking for a better React-DOM experience; what I was looking for was more like a less chaotic version of Gatsby to pre-render my React project and serve it statically.

A Solution Emerges: NextJS

A Less Chaotic GatsbyJS

I had heard about NextJS for a few years, so over the weekend when I built my little Svelte front-end prototype, I decided to do the same thing with NextJS -- and it clicked, right away. With NextJS, the file structure of your /pages directory becomes the URL structure of your site, so in this sense it's a bit like a Jekyll project, except instead of index.jekyll you have pages/index.js which is just a react component.

And if you give that file an export async function getStaticProps, it'll run that function at build time and your react component can use that data to pre-render the page. Contrast this with Gatsby, where you manage a single gatsby-node.js that fetches all the data for every page and decide which templates to run the data through (all at once in one long asynchronous function that can take several minutes to run each time). This is the kind of separation-of-code that I feel like React world is moving away from, in favor of full-featured, self-contained components that know how they are styled and how to fetch their own data.

Supported Plugins and Polyfills

And instead of the relative plugin-hell of the Gatsby world, I found a handful of key utilities like next/link, next/image, next/head, next/router, and so on. They're all maintained by the official team at Vercel, and they do basically what they are supposed to do.

Conveniently, NextJS also polyfills fetch, window, and console for the server, and process for the client, so you can write the same Javascript that runs in either environment without wrapping everything in "if this, do that, otherwise skip". The key here is that we are starting to get into truly Isomorphic Javascript -- i.e. it runs the same on client and server -- which allows the framework to do a bunch of interesting things.

For example, say you have a page which contains a mix of pre-render-able content and content that needs to be fetched per-user from the client. You can write your component with getStaticProps to fetch the main content, so when the user visits the page, the main article or post loads immediately and causes no server load, but then the framework runs through the React components/code as well in a process called "Hydration", activating any Hooks used for state, running any additional data-fetching that needs to happen from the client, and rendering whatever additional components rely on it.

Handling Forms and Live Preview

Within a few hours I had set up the app, rebuilt the basic page and component structure using Tailwind styles, and was fetching data from my Rails back-end. From this point it really wasn't hard to just abandon the previous React code base. What little state management and user interactions were there, were easier to rewrite using Hooks than to try and port over.

The big challenge would be the edit post screen! It really is my pride and joy. It's a side-by-side realtime preview with a markdown editor, and it's something I want to keep using and improving on as I go.

A screenshot of the editor described above, showing this post being written

I find composition screens really fascinating partly because they end up encapsulating team management practices, processes, and workflows. Composition screens need to be sensitive to and tweakable based on your needs for brand fidelity, how much you care about the "rapid response" use case, the amount of training and support you can provide to the people who will be using these screens, i.e. the content managers and campaigners.

I'm still on the lookout for a great solution, but in the meantime I choose to just write my own interface and make little improvements over time as I use and extend it.

Controlling the form for a Live Preview

This side-by-side preview requires "controlling" the form or hooking into its every update to re-render the preview. In the class-components version, I'd just bind some state to the inputs and re-render the component when anything changes. But this time I wanted to be a bit smarter about how I handled forms, so I went on the hunt for a good library to manage them.

On a previous job I had used Formik for some pretty big/complex forms, so I checked that out, along with a newer solution called React Hook Form. What I enjoyed about React Hook Form is that it had fully embraced the "Hook" paradigm; I just destructure formState and I get all these useful form state hooks like errors, isSubmitting, isSubmitSuccessful and so on (example code here).

  const {
    register,
    watch,
    handleSubmit,
    reset,
    formState: { errors, isDirty, isSubmitting, isSubmitSuccessful },
  } = useForm()

Likewise, I just pass that register function into the inputs themselves:

<input
  id="postTitle"
  type="text"
  {...register('title', { required: true, maxLength: 120 })}
  aria-invalid={!!errors.title}
  className={errors.title ? 'border-red-600' : ''}
/>

What I like about this approach is I'm not having to use a &lt;SpecialForm /&gt; component from one of these libraries and learn how to interact with it; I just work with the hooks and utilities, leaning on the library and the framework to get the functionality I need and inject it directly into the JSX and component logic where it makes the most sense.

And for the live preview, I can simply use the watch API:

const thePost = watch()

This thePost variable is a Hook that I pass to a component that renders the post, and every time the form state changes, I get an updated preview of the post. (I could debounce or throttle this update but I haven't needed to yet, partly because the markdown parser, remark-react is smart and doesn't re-render the entire article when one paragraph changes.)

Tailwind CSS

This writeup couldn't be complete without a mention of TailwindCSS and how fun and easy it was to use. By now a lot of ink has been spilled about how great it is, but you should probably just check it out for yourself. In May of 2021 I had a chance to use Tailwind and loved it, so I was keen to give it a try for this project because I'd be writing the front end afresh, trying to reproduce a somewhat clumsy, but visually good-looking Bootstrap site; I expected it would be a good test case, and it delivered.

To get a quick glance at how Tailwind is being used here, check out this site's global file: it isn't terribly remarkable except that it shows how little CSS is involved here. There are some very classes being declared here for headings, buttons and inputs, but the rest of the site's styles are handled entirely in utility classes in JSX. Here's a little sample of that 75-line long CSS file:

button.button,
a.button {
  @apply inline-block py-3 px-6 border rounded-md cursor-pointer;
}
button.button.outline,
a.button.outline {
  @apply text-cyan-700 hover:border-cyan-700 hover:underline;
}
button.button.solid,
a.button.solid {
  @apply text-white border-white bg-cyan-700
    hover:border-cyan-900 hover:bg-cyan-600;
}

The most common complaint I hear about Tailwind is that it means your markup is full of classes, and this is kind of true, but in the React world, I have no need for a .article-card CSS class because I have an ArticleCard React component, whose JSX contains the Tailwind utility classes I need. So I rarely find myself having to repeat myself, and in most cases where I do end up copying and pasting classnames, there's usually some little difference or another that would have necessitated a second component class or a variation -- or worse: a mixin.

The founder of Tailwind, Adam Wathan, wrote a great piece a few years ago about how all our CSS "Best Practices" have been failing us, explaining some of the philosophy behind Tailwind's approach. In short, it means I get to worry about my CSS and my classes in two places instead of three; there are still classnames in my JSX, and there's still a CSS framework in play, but it so closely resembles the names and properties in CSS itself that it's easy to remember, and there's no middle-layer, high-level component design language to write, remember and maintain.

To boot: with Tailwind CSS's Purge utilities, the result is a 20kb (unzipped) file, less than 1/8th the size of just stock/vanilla Bootstrap CSS.

Part 2: Supabase Back-End to Replace Rails

Like I said, I was originally planning to just rewrite the front-end and keep the Rails back-end, but once I got done with the front-end rewrite, I had these two separate repos michaelsnook.com-client and michaelsnook.com-server, and the next things I wanted to do immediately made it clear why Rails wasn't right for me: handling user logins, and handling image uploads. I actually did implement user logins on the Rails server, and it wasn't too bad, but when I got to image uploads... the number of different choices I would have to make, and the code I would have to write, all to do what seemed like an extremely common set of tasks... seemed unreasonable.

Enter Supabase

Supabase bills itself as an open-source alternative to Firebase, and they have gotten a ton of attention (and some decent funding) in recent months. They rely heavily on Postgres and PostgREST for its API, and using Postgres's Row-Level Security (RLS) to handle all authorization on the data you access. PostgREST is already a fantastic project, applying a thin (but fast and scalable) REST API layer ontop of your Postgres database, using your database's table and column structure to generate REST API endpoints for basic CRUD applications. And Supabase packages it up inside the officially maintained Supabase-JS library so I can write code as simple as this:

export async function fetchPostList() {
  const { data, error } = await supabase
    .from('posts')
    .select('*')
    .eq('published', true)
    .order('published_at', { ascending: false })

  if (error) {
    console.log(error)
    throw error.message
  }
  return data
}

Because I know supabase is handling access permissions for me, I have no concerns at all about writing this code in client side Javascript!

This has the effect of shifting the way the responsibilities are handled; a little bit more onto the database and a little bit more onto the react app, to the point that now, the need for a Rails app vanishes into thin air.

Built in Storage and Auth

Supabase-JS isn't just a JS wrapper for a PostgREST API; it is a collection of a handful of open source tools that they both use and contribute to. As their Architecture explainer says, they are also including:

  1. GoTrue to handle Auth
  2. StorageJS to upload images and other media. (It's actually a JS client for Supabase's own custom storage server, which they wrote about here.)
  3. A realtime API for streaming updates

The big key for me here is I no longer feel like I have to write Auth and Storage from scratch -- they just work. I create a new storage bucket and use RLS to grant access to any logged-in user. I log a user in with:

const { session, error } = await supabase.auth.signIn({
  email,
  password,
})

And the image upload is then just as simple as calling the upload with the bucket to upload to, the filename, and the file:

const { error } = await supabase.storage
  .from('images')
  .upload(filename, file, {
    cacheControl: '3600',
    upsert: true,
  })

This would have been hours and hours of work (for me) in Rails, plus an s3 bucket and CORS and IAM user. Supabase's auth system includes password recovery emails and magic links and sign in via SMS, so I never have to write those back-ends at all; I can just write a line of javascript in my client app and it works.

Conclusion

I love this stack. I think it's fantastic and I plan to keep using it, but I won't pretend it's for every project Most organizations won't have the luxury of ditching the server altogether and moving to Supabase or a similar Back-end As A Service (BAAS) approach. But many organizations will have one or two projects where BAAS makes sense!

Especially if you're using this kind of serverless or isomorphic approach for the "front end" application. NextJS and similar platforms are increasingly supporting lambda functions and incremental rebuilds; React has released Server-Side Components and these hosting platforms will likely support that as well. NextJS allows you to write functions in an /api/ folder which create API routes that execute only on the host server and have access to additional server-side Environment variables. So more and more, we can push functionality out of the back-end frameworks in helpful ways, ways that actually improve performance, security and scalability.

So I encourage folks to give BAAS a whirl, but the big take-away for me on this project has been how useful the combination of NextJS and Tailwind has been. It makes it easy to prototype, and then easy to build and customize and refine from there. And when I put the two pieces together, I finally start to feel like more and more of the things I want to build are straightforward, just a matter of writing the function.

When I think about how this stack would scale, either in traffic or in complexity, I think I'm finally feeling like I've got the tools and the knowledge that just about anything I want to build would be just a matter of taking the time to sit down and write the feature, rather than a hard technical barrier. But here are a few things about the current setup that I think could bite me in time, either for complexity or scale:

  • Moving to NextJS, I was able to do all this easy server-side generation because I'm hosting on Vercel, which seems like it could get expensive at scale, especially if we were using incremental site regeneration or server-side generation (not static but real-time, on the server). But right now there's no ISG or SSR, so I'd only be charged for their CDN (cheap) and SSG (infrequent).
    • It is possible to host the NextJS back-end on your own server, but it doesn't appear to be a point of focus for the team.
  • Similarly, Supabase seems like it will get expensive at scale. They offer their server(s) as open source software and in a Docker container, and it seems like it is a point of open-source pride for them to make sure that's a good experience.
  • The app doesn't really handle context at all right now. I started writing a little user context provider hook but supabase.auth.session() is fast and caches responses so it essentially acts as its own context store. But as an app running on this stack grew in size I would have to make some choices, given the specific needs at hand, about how to proceed.

For now, all of this is working great! I have to say the thing I'm least comfortable with is how much it seems that relying on NextJS means relying on Vercel's hosting long into the future.

I don't like vendor lock-in, and it makes me uncomfortable to think I'd build something small that relies down to its core on a technology that will be very expensive at scale. So I'm thinking about checking out Remix, which appears to be aiming for the same pre-render-then-hydrate approach as NextJS, and uses a similar approach to building routes with the filesystem, and running server-side functions defined in the same app.

It looks like actually a Remix port would be pretty easy to do, and would still work with Supabase and react-hook-form and Tailwind and all the rest of the tooling I've got now. I'd have to change some folder names and the way I define my data fetching functions (or maybe just what I call them). But there could be some benefits too -- in Remix, for example, you can define data fetching for, and then pre-generate individual components, not just pages, whereas I am not sure how gracefully NextJS will integrate server-side components when they are finally ready to be added into the framework.

But I am happy for now; no plans for another rewrite any time soon. What I'm looking forward to next is just using this nice, modern, lightweight, low-lift stack to build more things; maybe take another crack at writing Sunlo, the language learning app I have been dreaming and scheming about for 5+ years; or maybe just keep working on new features for this site like a media library and auto-save backups and the like. Right now though, I am so happy it's done, and I'm ready to put the code away for a little while and finish up some of the posts in my drafts folder. Wish me luck!