Building a mobile PWA on Vercel and PlanetScale
In 2022 my life partner, told me, that pretty much all hair journal apps out there are too complex for an average user. This is the kind of app, where you log the products, that you have used, and what effect they've had on your hair. Often hair enthusiasts have to go through dozens of product combinations in order to find the ones, that suit their specific type of hair. Managing the notes about what worked and what didn't can be a tedious process.
Having an expert by my side, I thought, that we could try to fill this potential void in the market — build a simple hair journal app. In the worst case, my partner would end up being the only user, which is still not too bad, since it would at least make their life easier.
That said, being on-call and paying for a constantly running SaaS on the side is not something I was looking forward to. Thus my goal was to find a serverless stack with reasonably limited free plan.
As the hair washing process is carried out in a bathroom, naturally, the client had to be a mobile app. Here I also did not want to use the maintenance-intensive native tech, so web seemed like the only reasonable choice.
Progressive Web App on a freemium serverless stack it is then.
Oh, and by the way, you probably have guessed it already — you're reading this article inside the app that I've eventually built. It has a section where users can read articles. In these articles my partner shares their expertise on various topics related to hair treatment. Hopefully, with time, this could grow into a channel, that brings new users from Google.
Here is the stack, that I ended up choosing:
(mostly borrowed from Brian Lovin 😊)
- PlanetScale for SQL database (MySQL-compatible)
- Sentry for reporting errors in production
All of these can be used for free with reasonable limits. Your app would have to get decent scale before you will find yourself needing to pay for infrastructure.
Setry's limits are probably the easiest to hit. That said, your app will still work fine without monitoring. Not so much without back-end or a database.
With Vercel, the main limitation is the number of functions you can deploy, so if you have many endpoints, you may eventually hit it. Finally, be aware, that you cannot use Vercel's free plan with a paid app.
The main point, however, is that the whole stack is serverless — you don't have to worry about scaling, uptime and updates. It's perfect for indie devs like me, who need a cheap, stable infra to run side-projects.
User Experience (UX)
I think, first it makes sense to lay out what kind of UX I wanted to achieve. My partner was the brains behind what needed to be built, but the rest I had to figure out on my own.
I really wanted to use this project to test my skills in crafting a really sleek and pleasant user experience, taking full advantage of the additional things, that PWA+Next.js+Vercel combo unlocks compared to regular web apps.
Here is the list of random UX-related thoughts and ideas, that I've assembled for myself:
- Bottom tab navigation with back button on top
- App navigation via URL-routing (browser back/forward button/swipe support)
- Single scrollable body (to not break things like tap-the-status-bar-to-scroll-to-the-top)
- Saving scroll positions
- Zero friction between anonymous and signed in modes
- Fast, fun, playful interactions
- Undoes instead of are you sure modals
- Add to home screen
- Splash screens
- Dark mode
- Offline mode
- Server-side rendering of React components
- Fast, CDN-ified blog right within the app (the one you are on)
Some are concrete technical features, some are more general guidelines, that I tried to incorporate into the UX while implementing.
Next, let's expand on how the whole thing works, starting with the web app.
The front-end is a regular React app with Server-Side Rendering (SSR) and routing through Next.js.
Progressive Web App (PWA)
PWA capabilities are added with next-pwa package plus a bunch of meta tags and
manifest.json. PWA's are hybrids between native apps and web apps (web pages in your browser). They can be added to the home screen, show splash screens and cache static resources. Caching enables resources to be loaded from the device in case there is no network connection (offline mode).
Seamless navigation was one of my main goals with hey.hair. The web app has to feel natural both as a website and as an app.
This meant giving every page a URL, that would function as the sole way of reaching this page. URL navigation ensures, that back/forward buttons/swipes work out-of-the-box, and, that you could always get back to the exact place within the app, if you refresh the page.
The latter being particularly important on Safari with its pull-to-refresh, that is quite easy to trigger by accident.
Another detail worth pointing out is that, thanks to SSR, your refreshed page would load fully-rendered right away, instead of flickering through the loading (or empty) state for a brief moment.
In addition to the navigation model described above, the app also needs to remember scroll positions of each tab, since inactive tabs are removed from the DOM tree.
You might be asking yourself — why not just move the active tab on top of inactive ones? In this case all tabs would remain in the DOM, persisting their scroll positions.
The downside is that you would have to do the scrolling via
overflow: scroll inside each tab as opposed to global scroll on the root element.
The differences between the two approaches are subtle, but noticeable. Keeping the scroll on the root element ensures a nicer interplay with native browser interactions (nav bar collapse, scroll to top), which contributes to an overall nicer UX.
Taking the idea one step further, I was able to emulate another native behaviour — going back within a tab returns to the last scroll position. To accomplish this, I remember scroll positions of the whole navigation stack within every tab.
Adding Dark Mode was as simple as defining CSS variables inside
@media (prefers-color-scheme) blocks, then mapping these variables to custom Tailwind colors. So instead of specifying colors like
text-gray-600 you would write
As for aesthetics, I went for a more translucent vibe with Apple-esque vibrant blurs.
Back-End and In-Between Logic
Back-end consists of Vercel Serverless Functions running on top of PlanetPlanet SQL database through Prisma, monitored by Sentry.
To make the app both snappy and offline-capable, all user actions are processed locally, data stored in-memory (in a React state variable). A queue is used to then sync changes one-by-one to the server.
Data is fetched from the server on startup, and refreshed when app moves to foreground (to account for changes from other devices, that might have happened in the mean time).
A forced refresh also happens, if the app detects concurrent usage on multiple devices. It does that by comparing the locally stored last action UUID with the one returned from the server.
This UUID is also handy in case the app tries to sync an already synced action. In this case the action is discarded, since the UUID is marked as processed in the database. Idempotency is the official term for this behaviour.
The aforementioned queue stores all actions locally, in local storage, until they are synced to the back-end. If network drops, the app just pauses syncing, and keeps processing mutations locally.
The locally processed data, is, however, stored only in memory. This is done to avoid having to migrate (in future versions of the app) two persistent schemas at the same time — one in the client and one in the database. And, oh boy… you don't want to be migrating and tracking compatibility in client schemas…
So what happens, if you reload the app without an internet connection?
In the browser — the page won't load. That's easy.
In PWA mode (app is added to home screen) — static resources are loaded from cache, in-memory data is populated from the last server fetch (so it is older), and the queue has a bunch of unsynced actions. The app cannot continue in this state, since new changes would be applied to old local data, and the server state (defined by the queue) would get out of sync with local data. In this case I just ask the user to find a network connection before continuing. Once the queue syncs to server, I re-fetch the data, and unblock the user. Not the nicest UX, but it will have to do for now.
For this project I wanted to experiment with replacing are you sure prompts with undo actions. The logic here is, that, by removing one extra click (or tap), you can make the UX more streamlined. In the rare case, that the user actually did not want to perform the harmful action, she can press Undo.
Every important mutating action in the app thus has an undo button, that appears in the floating toast at the top. Undoable actions have a syncing delay, so, if the user decides to undo them, the undo itself is performed only locally, where it is just a matter of replacing the entire React state with previous one. The server call in this case is never made, because it is delayed for the duration of the toast.
Users don't like to be forced to sign in, so (UX-wise) it made sense for me to make the app fully functional in both anonymous and signed in modes, with the most seamless transition between the two.
Technically, every user gets a 10-year (😅) cookie on first load. This cookie identifies their session on the current device. Signing in attaches your session to an email, allowing you to claim your data and share it between devices.
One down side for this frictionless setup is that, for anonymous users, clearing browser data effectively means loosing all your hey.hair data…
A cool features of Next.js is the ability to choose, per page, whether it is just a static HTML page, that loads data lazily, or a server-rendered page, that pre-loads data and pre-renders the HTML.
The former approach is best suited for static content like a blog or a public website. It allows Vercel to serve these pages from a CDN, improving the loading speed, which in turn positively affects search rankings.
This blog post is also served statically by Vercel Edge Network. And if you landed here from Google, then it means, that the search rankings are not too bad. 🤓
My DM's are open.