Published on December 24, 2020.
When I started working on Ambrook, adopting Next.js was a clear choice. I liked that it flexibly combines statically generated (SSG) pages (like blog posts, marketing sites), with server-side rendered (SSR) pages (like our app and account pages), and merges it all together with a smart client-side navigation scheme.
Firebase Authentication has been my go-to authentication choice for years. It handles all of the complexities of managing multiple authentication methods (password, Facebook, Google, phone number, magic link) into a single account system.
In using both, I hoped to create a best-in-class user experience. Here's what I wanted:
- Server-side rendered private pages that load the user's data without lots of spinners.
- Statically generated public pages, that show the same content for all users.
- Anonymous authentication that lets users start to input their information and use our app before being asked to make an account.
- Server-side redirects for users that load a private page without being logged in.
- Client-side redirects for users that click an onsite link to a private page without being logged in.
Furthermore, I had some developer experience requirements:
- Make it easy to introduce new pages and API routes that are auth-guarded.
- Reduce the likelihood that private pages are accidentally exposed.
While the basic integration was straightforward, the combination of these two tools created a fair number of cases to consider. Next.js lets users access pages by client side navigation and server side navigation. Firebase Authentication issues ID tokens that only last for an hour, after which they need to be refreshed using the client-side SDKs.
Authentication and SSR
Authenticating pages accessed via client side navigation is easy – the Firebase SDK will log users in automatically if they have an account, and private pages can check the currentUser
. But for the initial HTTP request, we'll need to use a cookie to store the user's ID token to be verified on the server. Here's how it works:
-
We sign in the user using the Javascript SDK:
-
On the client, we generate an ID token and write it to the user's cookies.
-
When the user next requests an SSR-powered page, the cookies are passed along in the request headers. We can then verify the ID token to validate a user's identity.
Anonymous Authentication
We want users to be able to start using our app without making an account, and connect their anonymous profile to a full account later if they wish. To do this, we're using Firebase's Anonymous Authentication. To integrate this into Next.js, we modify our custom _app.tsx
. Here's how it works:
- Every time a page renders on the client, our
_app.tsx
's render function will run. We use theuseEffect
hook to run our setup code on the first render only. - We set up a listener to changes in the Firebase Authentication state using
firebase.auth.onAuthStateChanged
, which takes a callback that contains the current user. It runs first withnull
, and then once Firebase has loaded the logged in user from the local session. - We either persist the user's credentials to the cookie, if they're logged in, or log them in anonymously if they are not.
Next, we'll set up the code that verifies token on the server.
Handling redirects
It's important that users get redirected to the login page if they're logged out but trying to access a private resource, no matter if they navigate on the client or directly to a private URL. I use the redirect
feature introduced in Next.js 10, which allows GetServerSideProps
to return a redirect object that will be executed on the client or server, depending on the user's context.
I wrote a wrapper function, withPrivateServerSideProps
that wraps a page's GetServerSideProps
function:
This code uses a helper function, isAuthenticated
, to determine if the user is authenticated. It uses the Firebase Admin SDK's verifyIdToken
function to validate the token and look up the user's basic details.
Here, the getCookie
function is parsing the cookie
header, and either returning the token
key or undefined
.
This makes for a very simple integration into a private page, like /account
:
Handling expired tokens
Firebase's ID tokens expire after about an hour, so it's likely that users that request a page will need to refresh their token in order to be authenticated. If this is the case, we redirect to the /login
page, just like if they were logged out. However, in this case, we detect the presence of an existing token and attempt to refresh it. Here, we use firebase.auth().onIdTokenChanged
, which detects not just changes in the user's logged in status, but also when their ID token is refreshed automatically by Firebase. On the login page, we detect this state, persist their new ID token to the cookie, and redirect them to where they where going.
Lots of cases to handle!
Between client-side and server side navigation, private and public pages, and the user's authentication state (logged out, logged in, expired token), there are a lot of cases to handle. But by carefully managing and correctly passing around the ID token, we can allow Next.js to authenticate a user from their ID token in whatever context we find them.
There were a couple of features we added that I didn't include here, but they all follow from the structure outlined above. Asynchronous API requests using fetch()
do not pass cookies by default, so I had to write a wrapper that includes the ID token in requests to my API routes. Also, we ended up building out "account-enhanced" pages that load basic content for logged out users and more content for logged in users. These pages follow the more traditional SPA model: Load the page and show a spinner, request the authentication state asynchronously, and then load the logged-in content (or not). We also use Redux in our app, but I removed that code from the samples above for clarity.
Hopefully this guide is helpful for your next project! If you have any questions, feel free to reach out to me on Twitter.