Authenticated server-side rendering with Next.js and Firebase

Colin McDonnell @colinhacks

updated November 20th, 2020

Feb 24, 2021: a previous version of this post contained a bug where a new cookie would be created each time you signed out and back in again. These (identical) cookies would stack up cause problems. If you're having issues, pass the { path: '/' } option to nookies.set callΒ β€” details below.

I had a hell of a time figuring out how to get Firebase Auth, Next.js, tokens, cookies, and getServerSideProps to play nice together. I'm hoping this breakdown will spare you the same suffering!

To jump straight to the sample repo, head to https://github.com/colinhacks/next-firebase-ssr πŸ€™

I'm currently building a new app. My goal is to do all data fetching inside getServerSideProps , just like the bad ole days of HTML templating. There are a lot of reasons for this.

  • It reduces the cognitive overhead compared to the classic JAMstack approach. The client-server dichotomy is gone β€” it's just a server! So I can deploy my entire application with a single command.
  • It solves the stale bundle problem. With a fully client-side rendered app, an user may use your application for days or weeks without doing a full page refresh. If the API changes underneath them, they may send API payloads that are no longer supported.
  • It strikes a great balance between dynamism and search engine optimization.
  • The user experience is unbeatable. With good caching and performant fetching, you can swap out the ubiquitous SPA spinner for snappy page loads.

As usual, Dan Abramov says it best:

It seems like the SSR-centric approach to building apps with Next.js is gaining steam fast. So I'm extremely confused why the official Next.js with-firebase-authentication example project doesn't demonstrate how to do it! It only demonstrates how to make authenticated POST requests to an API, not authenticate users inside getServerSideProps .

So below I explain how to use Next.js and Firebase Auth to:

  • sign in users (duh)
  • generate ID tokens
  • store those ID tokens as a cookie
  • auto-refresh the cookie whenever Firebase refreshes the ID token (every hour by default)
  • implement authenticated routes
  • authorize the user in getServerSideProps
  • redirect unauthenticated users to a login page from within getServerSideProps

Let's dive in.

#1 Configure your Firebase JS SDK

You've probably done this already. Should look something like this:

// firebaseClient.ts

import * as firebase from 'firebase/app';
import 'firebase/auth';

if (typeof window !== 'undefined' && !firebase.apps.length) {
  firebase.initializeApp({
    apiKey: 'APIKEY',
    authDomain: 'myproject-123.firebaseapp.com',
    databaseURL: 'https://myproject-123.firebaseio.com',
    projectId: 'myproject-123',
    storageBucket: 'myproject-123.appspot.com',
    messagingSenderId: '123412341234',
    appId: '1:1234123412341234:web:1234123421342134d',
  });
  firebase.auth().setPersistence(firebase.auth.Auth.Persistence.SESSION);
}

export { firebase };

The firebase.apps.length check is a clever way of preventing Next.js from accidentally re-initalizing your SDK when Next.js hot reloads your application!

#2 Configure your Firebase Admin SDK

You've probably done this already too. Check out the Firebase docs for guidance. It'll look something like this:

// firebaseAdmin.ts

import * as firebaseAdmin from 'firebase-admin';

// get this JSON from the Firebase board
// you can also store the values in environment variables
import serviceAccount from './secret.json';

if (!firebaseAdmin.apps.length) {
  firebaseAdmin.initializeApp({
    credential: firebaseAdmin.credential.cert({
      privateKey: serviceAccount.private_key,
      clientEmail: serviceAccount.client_email,
      projectId: serviceAccount.project_id,
    }),
    databaseURL: 'https://YOUR_PROJECT_ID.firebaseio.com',
  });
}

export { firebaseAdmin };

Alternatively you can store the secret.json file elsewhere on your file system and tell Firebase where you find it using the GOOGLE_APPLICATION_CREDENTIALS environment variable. For details check out the Firebase Admin setup documentation here.

#3 Create a Context and provider

Now we're getting to the meat and potatoes. We're going to use React Context to implement the authentication logic. Let's start by writing a simple provider:

import { createContext } from 'react';

const AuthContext = createContext<{ user: firebase.User | null }>({
  user: null,
});

export function AuthProvider({ children }: any) {
  const [user, setUser] = useState<firebase.User | null>(null);

  // handle auth logic here...

  return (
    <AuthContext.Provider value={{ user }}>{children}</AuthContext.Provider>
  );
}

In the AuthProvider component we initialize a user state and pass it into our React component hierarchy using React Context. If you haven't seen this pattern before, I recommend reading the official docs on Context before continuing.

Listen for token changes

Now let's add a useEffect hook that initializes our Firebase authentication listener:

import nookies from 'nookies';

const AuthContext = createContext<{ user: firebase.User | null }>({
  user: null,
});

export function AuthProvider({ children }: any) {
  const [user, setUser] = useState<firebase.User | null>(null);

  useEffect(() => {
    return firebase.auth().onIdTokenChanged(async (user) => {
      if (!user) {
        setUser(null);
        nookies.set(undefined, 'token', '', { path: '/' });
      } else {
        const token = await user.getIdToken();
        setUser(user);
        nookies.set(undefined, 'token', token, { path: '/' });
      }
    });
  }, []);

  return (
    <AuthContext.Provider value={{ user }}>{children}</AuthContext.Provider>
  );
}

This is where the magic happens. We're hooking into the firebase.auth().onIdTokenChanged event listener; if you've never used it, it's identical to onAuthStateChanged but it also fires when the user's ID token is refreshed.

In the onIdTokenChanged callback, we check if the user is still signed in. If they are, we set the user with setUser .

Here's the most important part: we also set a token cookie that contains the user's ID token. (To accomplish this, I'm using the excellent nookies package by the great and powerful @maticzav.)

Feb 24, 2021: passing the { path: '/' } option to nookies.set prevents your app from creating a new token each time this code runs!

Now all outgoing requests β€” both API requests and page navigations! β€” will contain the user's ID token as a cookie! There's an example of this further down the page.

Token refresh

A previous version of this post incorrectly assumed that Firebase automatically refreshes the ID token on an hourly basis. As it turns out, that's not true. Firebase only does so if it is maintaining an active connect to Firestore or Realtime Database. If you aren't using one of these, services we need to refresh the tokens ourselves.

Below is the final version of our AuthProvider. Note the new useEffect hook that automatically force-refreshes the Firebase token every 10 minutes.

import nookies from 'nookies';

const AuthContext = createContext<{ user: firebase.User | null }>({
  user: null,
});

export function AuthProvider({ children }: any) {
  const [user, setUser] = useState<firebase.User | null>(null);

  // listen for token changes
  // call setUser and write new token as a cookie
  useEffect(() => {
    return firebase.auth().onIdTokenChanged(async (user) => {
      if (!user) {
        setUser(null);
        nookies.set(undefined, 'token', '', { path: '/' });
      } else {
        const token = await user.getIdToken();
        setUser(user);
        nookies.set(undefined, 'token', token, { path: '/' });
      }
    });
  }, []);

  // force refresh the token every 10 minutes
  useEffect(() => {
    const handle = setInterval(async () => {
      const user = firebaseClient.auth().currentUser;
      if (user) await user.getIdToken(true);
    }, 10 * 60 * 1000);

    // clean up setInterval
    return () => clearInterval(handle);
  }, []);

  return (
    <AuthContext.Provider value={{ user }}>{children}</AuthContext.Provider>
  );
}

Why use React Context?

You may be wondering why you need to use React Context at all. Let's consider a more naive approach to writing this hook.

// DONT DO THIS

export const useAuth = () => {
  const [user, setUser] = useState<firebase.User | null>(null);

  useEffect(() => {
    return firebase.auth().onIdTokenChanged(async (user) => {
      if (user) {
        setUser(user);
      } else {
        setUser(null);
      }
    });
  }, []);

  return { user };
};

This actually works fine. However it would create a new user state variable and a new listener every time you use the hook. We want to make sure the user variable is the same everywhere throughout our application to avoid difficult-to-debug synchronization issues. We only want a single reference to the currently signed in user that is shared throughout our app.

#4 Add your provider to a custom App

The documentation for setting up a Custom App page with Next.js are at https://nextjs.org/docs/advanced-features/custom-app. It's the ideal place to add "wrapper" code that you want to exist on all other pages β€” like Context providers.

// pages/_app.tsx

import type { AppProps } from 'next/app';
import { AuthProvider } from '../auth';

function App({ Component, pageProps }: AppProps) {
  return (
    <AuthProvider>
      <Component {...pageProps} />
    </AuthProvider>
  );
}
export default App;

#5 Create the useAuth hook

Now that the context and provider is set up, our actual useAuth hook is dead simple:

export const useAuth = () => {
  return useContext(AuthContext);
};

#6 Check the token in getServerSideProps

Below is a complete working example of how to implement an authenticated route. If the user isn't properly signed in (e.g. if the token cookies doesn't exist or if the token verification fails) the user is redirected to the /login page.

Otherwise, you can confidently fetch and return data belonging to the user!

// /pages/authenticated.tsx
import React from 'react';
import nookies from 'nookies';
import { InferGetServerSidePropsType, GetServerSidePropsContext } from 'next';

import { firebaseAdmin } from '../firebaseAdmin';

export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
  try {
    const cookies = nookies.get(ctx);
    const token = await firebaseAdmin.auth().verifyIdToken(cookies.token);

    // the user is authenticated!
    const { uid, email } = token;

    // FETCH STUFF HERE!! πŸš€

    return {
      props: { message: `Your email is ${email} and your UID is ${uid}.` },
    };
  } catch (err) {
    // either the `token` cookie didn't exist
    // or token verification failed
    // either way: redirect to the login page
    ctx.res.writeHead(302, { Location: '/login' });
    ctx.res.end();

    // `as never` prevents inference issues
    // with InferGetServerSidePropsType.
    // The props returned here don't matter because we've
    // already redirected the user.
    return { props: {} as never };
  }
};

export default (
  props: InferGetServerSidePropsType<typeof getServerSideProps>
) => (
  <div>
    <p>{props.message}</p>
  </div>
);

The full solution

If you've made it this far you must think the ideas in this post are pretty useful! Consider retweeting this to get the word out: πŸ™Œ

The full code of this project β€” plus some other goodies! a sign up form! a logout button! β€” is available at https://github.com/colinhacks/next-firebase-ssr. Clone that repo to hit the ground running. πŸƒπŸ½β€β™‚οΈπŸ’¨

By the way, I'm currently working on an extremely rad library that lets you define your API with TypeScript and auto-generate a strongly typed SDK that you can safely use on the client. I think it could be a gamechanger for full-stack TypeScript/Next.js development. Join the mailing list below to stay updated.

Subscribe

Hacker News TL;DR Podcast

Colin is a YC-backed founder and open-source developer. Kenny is a human-computer interaction whiz at Apple. Two best friends report daily from the depths of Hacker News.