DEV Community

Cover image for Authentication with xState 5, Firebase and Next.js App Router
Georgi Todorov
Georgi Todorov

Posted on • Edited on • Originally published at z-lander.hashnode.dev

Authentication with xState 5, Firebase and Next.js App Router

TL;DR

If you just want to see the code, it is here. You can also have a look at the xState 4 implementation here.

Background

In our team, we've been using xState and Firebase for a couple of years to develop a mobile app, and we are naturally continuing with the same stack for the web app. I’ve already started sharing my experience with React Native and I’m planning to do the same with Next.js. To start with the authentication seems like the obvious choice.

Use case

To experiment with the authentication capabilities of the stack, we will build a simple website with two pages: a Sign In screen and a Dashboard page accessible only to authenticated users.

Disclaimers

Integrating Firebase in Next.js is quite straightforward and covered in enough online materials, so we won't discuss it in this post. The focus will be on integrating both technologies with xState. Just for clarity, here's the Firebase config file:

import { initializeApp, getApps } from "firebase/app";

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_firebaseApp_ID,
};

let firebaseApp =
  getApps().length === 0 ? initializeApp(firebaseConfig) : getApps()[0];

export default firebaseApp;
Enter fullscreen mode Exit fullscreen mode

Also, to simplify things further, we will use the signInAnonymously method, which works the same way as authenticating with email/password or phone but doesn't require user input.

Implementation

xState machine

Usually, when working with xState and React, I reach a point where I need a globally accessible machine. That's why I start by creating an appMachine and pass it to the createActorContext method so that it can be used from the React context. This way, we can keep the authentication logic in a single place and send events to/from any page.

Key parts of the machine are the root GO_TO_AUTHENTICATED and GO_TO_UNAUTHENTICATED events. They lead the user to the correct state and, respectively, to the correct screen.

The userSubscriber actor (which will be explained in a bit) plays the role of an orchestrator, which is in charge of listening for the user object from Firebase and targeting the appropriate state with the one of the already mentioned events.

The machine consists of three main states. loading is the initial state that is active until we know the user's status. After that, we transition to one of the other two states - authenticated or unauthenticated. They both have substates and are idle initially. When the user is authenticated, they can call the SIGN_OUT event to transition to the signingOut substate, which is in charge of invoking the signOut method. The unauthenticated structure is similar, with the difference that instead of signing out, it contains the signing-in logic.

import React, { PropsWithChildren } from "react";
import { setup } from "xstate";
import { createActorContext } from "@xstate/react";

const appMachine = setup(
  {
  // machine options
  },
}).createMachine({
  invoke: { src: "userSubscriber" },
  on: {
    GO_TO_AUTHENTICATED: { target: ".authenticated" },
    GO_TO_UNAUTHENTICATED: { target: ".unauthenticated" },
  },
  initial: "loading",
  states: {
    loading: { tags: "loading" },
    authenticated: {
      on: { SIGN_OUT: { target: ".signingOut" } },
      initial: "idle",
      states: {
        idle: {},
        signingOut: {
          invoke: { src: "signOut" },
          onDone: { target: "idle" },
        },
      },
    },
    unauthenticated: {
      on: { SIGN_IN: { target: ".signingIn" } },
      initial: "idle",
      states: {
        idle: {},
        signingIn: { invoke: { src: "signIn" }, onDone: { target: "idle" } },
      },
    },
  },
});

export const AppContext = createActorContext(appMachine);

export function AppProvider({ children }: PropsWithChildren<{}>) {
  return <AppContext.Provider>{children}</AppContext.Provider>;
}
Enter fullscreen mode Exit fullscreen mode

Firebase

In order to retrieve the current user, Firebase recommends using the onAuthStateChanged observer, which is a perfect fit for a callback actor. In the callback, we just have to check the user value. If it is null, the user is unauthenticated; otherwise, we trigger the GO_TO_AUTHENTICATED event.

For the signIn actor, as mentioned before, we go with the signInAnonymously method, and for the signOut actor, we resolve the auth.signOut() promise. Both of these will reflect on the user that is being observed in the userSubscriber service.

import { fromCallback, fromPromise, setup } from "xstate";
import { onAuthStateChanged, getAuth, signInAnonymously } from "firebase/auth";

import firebaseApp from "@/firebase";

const auth = getAuth(firebaseApp);

const appMachine = setup({
  types: {
    events: {} as
      | { type: "GO_TO_AUTHENTICATED" }
      | { type: "GO_TO_UNAUTHENTICATED" }
      | { type: "SIGN_IN" }
      | { type: "SIGN_OUT" },
  },
  actors: {
    userSubscriber: fromCallback(({ sendBack }) => {
      const unsubscribe = onAuthStateChanged(auth, (user) => {
        if (user) {
          sendBack({ type: "GO_TO_AUTHENTICATED" });
        } else {
          sendBack({ type: "GO_TO_UNAUTHENTICATED" });
        }
      });
      return () => unsubscribe();
    }),
    signIn: fromPromise(async () => {
      await signInAnonymously(auth);
    }),
    signOut: fromPromise(async () => {
      await auth.signOut();
    }),
  },
}).createMachine(
  {
  // machine definition
  },
});
Enter fullscreen mode Exit fullscreen mode

Next.js

From here, we can continue by consuming the context. Wrapping the children with AppProvider in the RootLayout gives access to the global machine from all layouts and pages.

import { AppProvider } from "@/contexts/app";
import { Loader } from "@/components/Loader";
import { StateRouter } from "@/components/StateRouter";

const inter = Inter({ subsets: ["latin"] });

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <AppProvider>
          <Loader>{children}</Loader>
          <StateRouter />
        </AppProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

The purpose of the <Loader> component is to prevent pages from rendering before the user data is loaded. The AppContext.useSelector() hook always updates on state changes, and when the state is loading, we just display a placeholder screen.

"use client";

import { PropsWithChildren } from "react";
import { AppContext } from "@/contexts/app";

export function Loader({ children }: PropsWithChildren<{}>) {
  const state = AppContext.useSelector((snapshot) => {
    return snapshot;
  });

  return state.matches("loading") ? (
    <main>
      <div>Loading...</div>
    </main>
  ) : (
    children
  );
}
Enter fullscreen mode Exit fullscreen mode

We handle the actual navigation in the StateRouter component. It is inspired from the router events example in the Next.js documentation. We listen for changes in the app machine state and once one of the authenticated or unauthenticated states is active, the corresponding page will be loaded. If the user already exists, they will be navigated to the dashboard, which is located at the root - /. Otherwise, they should be redirected to the /sign-in page.

"use client";

import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { AppContext } from "@/contexts/app";

export function StateRouter() {
  const router = useRouter();
  const state = AppContext.useSelector((snapshot) => {
    return snapshot;
  });

  useEffect(() => {
    if (state.matches("unauthenticated")) {
      router.push("/sign-in");
    } else if (state.matches("authenticated")) {
      router.push("/");
    }
  }, [state.value]);

  return null;
}
Enter fullscreen mode Exit fullscreen mode

In my experience, integrating xState with the navigation lifecycle of another framework is one of the most challenging aspects when setting up the initial application architecture. While this approach may not be the most scalable solution, it works well in terms of code separation.

Conclusion

From the little that I tried out, I'm satisfied with the results so far, but I still have concerns about how the application will grow when adding more pages and interactions with Firebase.

Image of AssemblyAI tool

Transforming Interviews into Publishable Stories with AssemblyAI

Insightview is a modern web application that streamlines the interview workflow for journalists. By leveraging AssemblyAI's LeMUR and Universal-2 technology, it transforms raw interview recordings into structured, actionable content, dramatically reducing the time from recording to publication.

Key Features:
🎥 Audio/video file upload with real-time preview
🗣️ Advanced transcription with speaker identification
⭐ Automatic highlight extraction of key moments
✍️ AI-powered article draft generation
📤 Export interview's subtitles in VTT format

Read full post

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs

👋 Kindness is contagious

Dive into an ocean of knowledge with this thought-provoking post, revered deeply within the supportive DEV Community. Developers of all levels are welcome to join and enhance our collective intelligence.

Saying a simple "thank you" can brighten someone's day. Share your gratitude in the comments below!

On DEV, sharing ideas eases our path and fortifies our community connections. Found this helpful? Sending a quick thanks to the author can be profoundly valued.

Okay