Rebuilding Our Signup Flow With Gatsby
The problem
Thanx is a customer engagement platform for real-world merchants. It connects users and merchants so users can earn rewards at merchants they love and merchants can gain more insights into who their customers are. This connection happens for the first time on the signup page, so the user’s experience there needs to be seamless and leave a good impression. Our existing signup flow, part of a server rendered rails app, was starting to show its age and didn’t look like the experience we wanted.
With that in mind, we decided to rebuild our signup experience, making sure to focus on:
- Performance Most users sign up on a phone, so the signup app needs to load quickly and feel snappy on a mobile connection.
- SEO Having good SEO helps our merchants improve their signup numbers and drive their success on Thanx.
The app
Our app has a main landing page for each merchant on our platform. Each merchant has different programs that a user can sign up with.
A distilled version of the actual app looks like this:
/:merchantId/:programId
- Landing page for signing up for a merchant and program. It has a field for email and some text about the program/:merchantId/:programId/info
- Page where the user enters full name, phone number etc./:merchantId/:programId/success
- Success page after the user signs up
There’s a couple of special cases
/:merchantId/join
- The main landing page for a merchant. Every merchant has ajoin
program/:merchantId/
- Redirects to/:merchantId/join
One thing to notice is that users will always start on the landing page. This will have implications for how we design our app.
Choosing Gatsby
Before choosing a technology, we evaluated a few common options:
- Next.js: Next.js is a popular framework for server side rendering React apps. However, the additional complexity of running a node server seemed like overkill for our app, which only has one main landing page that would benefit from server rendering.
- Ad-hoc prerendering: We put together a prototype script to prerender our application, but we thought this would become cumbersome to maintain as our app grew.
We eventually settled on using a Gatsby.
Gatsby is a static site generator built around React, primarily for building things like blogs or brochure sites. But it can also be use to build apps that prerender some initial pages and then behave like a client react app from there, which Gatsby calls a hybrid app.
This is perfect for our use case! Having a prerendered landing page makes the initial load fast and gives search engine crawlers something to index. Also, Gatsby’s programmatic page creation api reduces the boilerplate required to prerender those landing pages for each merchant in our database. This seemed like a good fit.
Building the app
The client side
We have one Gatsby page component which serves as the main entry point to our app. During Once the app loads, routing responsibility gets handed off to the client side router (we used @reach/router).
The GraphQL query here is a Gatsby page query. Once we hook up our GraphQL api, Gatsby will handle making the query at build time and supplying data to our page component in data
.
I’ve intentionally left out the state management part, which could be its own post. We ended up using react context, but there are a variety of tools (Redux, Mobx, Apollo etc.) to choose from.
Prerendering /:merchantId/join
Gatsby has a build step where it takes your app code and generates static html and js files to deploy to a web server. During this step, we use Gatsby’s createPages
api to programmatically create our landing pages, which Gatsby then uses to generate html. We start with the /:merchantId/join
page for each merchant, since the "join" program is by far the most common signup avenue for users. First, we load the merchant ids from our server and then create a signup page with that merchant handle. It looks something like this:
Including the ensures that all nested routes use the same Gatsby page component. If we didn’t include this, Gatsby would mount a different page component instance for a route like /${merchantId}/join/info
. Routes that match the matchPath
will use the same merchant and program data as /${merchantId/join}
, so we don't have to do any refetching.
Now we can run gatsby build
and populate our public/
folder with a bunch of prerendered html pages!
Handling Non Prerendered Routes
Our app works great for the most common case of /${merchantId}/join
, but merchants can have any number of additional programs that a user can sign up for. How can we make sure those programs are also supported?
One promising option is to query for every program of every merchant and prerender a landing page for each one. This is a possible path forward but comes with a couple of drawbacks:
- Our build step would make
n merchants * m programs
queries, which makes our build step pretty slow. We could do a better job of batching queries to relieve this, but we'll still load a lot of data - If a merchant adds a new program, we have to rebuild the app to serve this new program
The last point is a deal breaker. We need to allow users to sign up for all programs at all times, so we need a mechanism to handle program’s that haven’t had pages built yet.
To do this, we need to leverage how Gatsby handles 404s. If the user requests a page that we haven’t prerendered, Gatsby responds with a blank 404 page, loads the js bundle and let’s the client code render an appropriate error page. Instead of immediately rendering an error page, our app can try our client side routes first and load the correct merchant and program.
We configure Gatsby like this:
Using a matchPath
of /*
tells Gatsby to use our index.js
page for all requests that don't match another page (like the /join
pages we created earlier). Our client side routing can then take over and render the appropriate components, or a client side 404 page if the route isn't supported in our app.
Improving the experience for other programs
Great! Now our app can handle signing up for any merchant or program. But the experience falls back to a client side rendering for a lot of cases. Especially on slow mobile connections, users stare at a blank screen or a spinner while the react app loads. Since we’ll always have the merchant data, we’d like to show as much of the landing page as possible while the react app and program data load.
We accomplish this by prerendering a version of the landing page for an unloaded program, and then using matchPath
to match every /:merchantId/:programId
to it. (/join
has higher precedence due to the way Gatsby's internal routing works)
Server config
Now we have skeleton pages for unloaded programs, but that’s not enough to get our server to serve them. We need to configure our static web server to redirect all “/:handle/*/” requests to our prerendered html at “/:handle/program/index.html”. This will vary depending on the web server in use, but we generate a bunch of location
entries for nginx that look like this:
location ~ ^/a-merchant-id/[^/]*/$ {
try_files $uri /a-merchant-id/program/index.html @404;
}
Learnings
This is close to how we have our signup flow set up in production and it’s been going well so far, although I’m sure we’ll have to tweak our prerendering in the future. Here’s what we learned.
What went well
This approach allowed us to build a fast and SEO friendly app with minimal boilerplate thanks to Gatsby’s static rendering.
From a development perspective, transitioning from create-react-app was easy once we cleared the initial hurdle of setting up the Gatsby build config. The app is just a normal react app once it loads. Deploying is also easy since we only need a small build step before serving static files.
What was challenging
We did run into some challenges while building this app.
- Prerendering requires more complex setup: This isn’t unique to our approach, but server rendering an app requires extra setup for a lot of tools used in the app (CSS-in-JS, Apollo etc.). Our inexperience in dealing with this made transitioning to server rendering more challenging. Gatsby’s large plugin ecosystem helped ease our learning curve.
- Build step is hard to test: Because our build setup is more complicated that a typical Gatsby static site build and involves some tricks in the web server config, it’s difficult to fully test. We’ve done our best to add end to end tests to give us confidence here.
Conclusion
Overall, Gatsby worked well for us for this project. It accomplished our performance and seo goals through it’s static rendering. While using Gatsby for a hybrid app required more time and learning to set up than we had hoped, once we cleared this hurdle, our team was very productive in building the app. We’re satisfied by our choice of technology and will look at using Gatsby for future projects where it makes sense.
Thanx!
Thanks for reading! I hope getting a look at our approach for this kind of app is useful for your projects.