Remix and Firebase - So many options!
Remix and Firebase, so easy ... maybe #
I decided to dig into Remix since they released version 1.0 just recently. I also decided, wouldn't it be great to have this out-of-the-box isomorphic app server do all the heavy pluming and then use Firebase for all the other stuff! That would free me to focus on the intricacies of whatever app I'm currently building. However, I quickly found myself in a state of confusion as I wondered, "should I run Firebase in the client or on the server?". Usually, this is a no-brainer, just run it on the client (since it's already running there, if it's a react app) and be done with it. But Remix is different; Remix runs on the client and on the server. So where should I run Firebase?
I narrowed down my options:
- Implement server-side using Firebase JavaScript SDK
- Implement client-side using Firebase JavaScript SDK and sessions
- Implement server-side using Firebase Admin SDK
In this article, I'm not going to go into all the details of the three options above. Instead, I will share my overall experience with each and what I learned along the way. The implementation that I felt best leveraged both Remix and Firebase is what I've chosen to go into more detail about in my four-part series on Remix with Firebase.
Server-side implementation of JavaScript SDK #
Naively I thought I could easily use Remix's server-side loaders and actions to integrate the JavaScript Firebase Auth library. And, to be honest, it was pretty easy to implement! First, I created a Firebase project, added a web application, created a firebase.js file to initialize my app and get the auth object; typical Firebase setup. I then created an auth helper file to abstract the login, logout, and currentUser functionality for my app. Each function wrapped the required Firebase Auth function (e.g. signInWithEmailAndPassword, signOut and currentUser).
Note: I've decided to stick with signInWithEmailAndPassword
for my implementation walkthrough.
I created two routes: 1) the index, which presents a login form, and 2) a secrets route, which would become my protected (i.e. needs auth) route. I got to work, implementing a an action on the index to call signInWithEmailAndPassword
and redirect to the /secrets
route. Perfect, submit the form with email and password and Firebase does its thing. Works like a charm. To simplify things, I created a separate /app/utils/auth.js
file to manage my user authentication tasks. The file had three functions:
login()
- rips the email and password from the index login form and uses Firebase to sign inlogout()
- uses Firebase to sign outcurrentUser()
- returns the current user in Firebase auth
The loader in the /secrets
route performs a check for currentUser()
. The loader would redirect to the /index
if currentUser()
did not return a user object. I added a logout link on the page that leads to a new route, /logout
, and the /secrets
route is done (no action necessary for now). In the new /logout
route, I kept it simple and implemented a loader that calls logout()
and redirects to the /index
route. Done. Everything works great.
Long story short, this implementation worked. But, it all seemed too simple. I thought about the implementation, then realized, "I've been doing all of the interaction with Firebase on the server!". Running Firebase in the action and loader functions meant that auth was only running on the server itself, not the client. Thinking about it now that it's done, it all makes sense (I could have saved some time by doing the thinking upfront). My server-side implementation makes no calls to the auth service from the client.
I trepidatiously opened a different browser and entered my localhost server address in the address bar. Sadness ensued. The protected route page loaded and displayed the logged-in user name, just as I had suspected. Logging out on this browser also logged me out in the other one. So, this implementation works well if you'd like a "login everywhere once!" solution. I don't. Onto the next idea!
Integrating JavaScript SDK with server sessions #
I decided to overcome the issue I encountered in my first attempt by moving the code from the server to the client. I extracted all interactions with Firebase Auth out of the loaders and actions and placed them into client-side only code (so, react components). The change meant that pretty much every loader and action function had to be removed or repurposed. Wiring up the client to work with the auth library took a little more thought and effort than I thought it would. Honestly, I felt a little gross about what I was doing.
I kept thinking, "this feels like it goes against everything that Remix is all about." By the time I was done the auth switch to the client, Remix was serving up a react app - could have just used a static hosting provider - but it is what it is. To handle requests on the server, I still wanted to take advantage of loaders and actions to check and redirect for protected routes. In addition, I needed some way of knowing on the server whether a user was logged in or not (the auth.js
file was basically kept intact and just implemented in useEffect
hooks and event handlers).
Sessions to the rescue! I included some code in the client-side login function to create a cookie with post the current user's idToken
(don't judge me) to a new /login
route. The /login
route only has an action, which would rip out the user's idToken and then verify the user with the Firebase Admin Auth service ... except I found it impossible -- I don't want to say impossible, but it requires more time and effort than I'm willing to throw at it -- to load both the firebase/auth
and firebase-admin/auth
objects in this type of isomorphic app.
Basically, I found that because you can't initializeApp()
more than once, no matter how many different ways I tried to check for the client vs. server loading of Firebase, I couldn't get it to work. So, I thought, "I'll just default to the Firebase Auth REST API; that should fix it." But the REST API only works with Firebase proper, which means abandoning the local emulator. FRUSTRATION!
Note: it turns out you can use the Emulator to run the REST API requests. Just tack on the /identitytoolkit.googleapis.com/...
part of the URL to your local Emulators URL (http://localhost:9099
). Now I know.
For the sake of experimentation, I added the Auth service and created a user through my Firebase console to begin testing. Next, I removed the useEmulator
code and reverted the project ID to ensure my test app used Firebase and not my local emulator. Success. Okay, so now to implement the REST API call in my /login
action to get the user info. I was pretty bummed to find that there is no way to verify a user with the REST API. The "get user data" endpoint was the closest stand-in I found.
I tested this out, and sure enough, I was able to post the idToken
to /login
, get the user's displayName
, and then create a session cookie to store the idToken and displayName for the user for use in subsequent requests. With the session in place, my protected route can now make use of the session cookie to determine whether the user is logged in or not and render or redirect as needed. I also updated the /logout
action to destroy the session in a similar fashion.
After all this, I can say that it works. But, I still feel gross about the implementation and the lack of an option to verify a user's id. So, integrating the JavaScript SDK with a custom backend session service is possible. Still, I'm not convinced it's the best way to do it.
Moving to a full server-side Admin SDK implementation #
I explored a third option utilizing the Admin SDK for full server-side implementation. This meant that I would have no interaction with the Firebase SDKs on the client (or so I thought, at first). In the beginning, I convinced myself that it would be possible to create an auth solution using Admin SDK. Still, I wasn't sure how this implementation would pan out when using other Firebase services (e.g. Firestore).
The following are the things I thought I would need before even getting started:
- Firebase Admin SDK for backend loader/action processing
- Sessions to communicate current user auth state to the client (I don't want to have to verifyId on every request ... but, maybe I do)
- That's it
This implementation is similar to the previous one but with JavaScript SDK work only being done during the initial user login. The JavaScript SDK is used during the login process to sign in via Firebase Auth, retrieve the idToken, immediately sign out of Firebase Auth, and wait for the server to create a session. Easy. From then on, the app will treat the user as "authenticated" until the session times out, or the user explicitly logs out, which will trigger the server to destroy the session.
I created a number of helper session functions to work with the session cookie:
createUserSession(user, redirectTo)
- receives a user object (with Firebase idToken) and the location to redirect to upon successful session creation (uses Firebase Admin Auth to create the session cookie to be stored)getUserSession()
- retrieves the session (helpful in other functions)destroyUserSession(request, redirectTo)
- destroys the session and redirects to theredirectTo
locationrequireUser(request, redirectTo)
- uses Firebase Admin Auth to verify the idToken in the session (if it exists) and returns the authenticated user
All session helper functions are only ever called from loaders or actions to ensure they never execute the client. I found I could set up user authentication and route protection similar to my first implementation, but without the "single login for everyone" issue. Everything feels snappy, and I'm much happier with this implementation than either of the other two. Because I've chosen to use this third implementation in a more extensive walkthrough series, I'm not going to go further into the details here.
You can get the full walkthrough of this implementation in my Remix with Firebase walkthrough series.
Summary #
I found that I enjoyed working with Remix through all the time I spent with it over the past few days. While working through the Firebase integrations, I had a feeling that I hadn't had for quite some time. A good feeling. It's as though Remix has re-sparked my interest in software development again, something that had waned in recent times. I am by no means a Firebase expert, and I likely missed something that may have eased some of the pain points I found while wrestling with the various auth implementations.
Still, I feel quite good about the final implementation using Firebase Admin Auth and sessions for user management. Like I get to relive the glory years of working with backend frameworks while still enjoying the benefits of modern front-end tooling. I had been waiting for Remix to hit v1.0 before giving it a try, and I must say it hasn't disappointed me. I think I'll continue working with it and digging in under the hood to explore all its inner workings. I'll also be sure to share my findings with everyone here too!
References #
- Remix Docs
https://remix.run/docs/en/v1 - Firebase Auth
https://firebase.google.com/docs/auth