The open blogging platform. Say no to algorithms and paywalls.

I Built a Serverless Live Chat App with Next.js, Fauna, and WunderGraph for GraphQL Live Queries

A step-by-step guide to creating a scalable, real-time chat app using Serverless technologies, with a little help from NextAuth.js for GitHub sign-ins. Who needs WebSockets when you've got Live Queries?"

image

If you're building apps that work with realtime data, you're probably using WebSockets. These allow a web browser and a web server to communicate in real-time by keeping a persistent connection open between the two - data is pushed to clients as soon as it becomes available, rather than having the client constantly poll the server to check for new data.

But what if your app is serverless - running on infrastructure managed by a cloud provider like AWS or GCP?

To facilitate high elasticity and fault tolerance, these environments are designed to be stateless and ephemeral by their very nature, meaning there's no guarantee that your code will be running on the same physical server from one request to the next - and thus no persistent connection between the client and the server.

So what's the solution to building realtime apps on serverless architectures? Let's find out! Let's build this realtime Slack/Discord-esque live chat using Next.js as our JS framework, Fauna (using GraphQL) as our database, and WunderGraph as a backend-for-frontend that facilitates the link between the two. Our app will also use GitHub sign-ins, and we'll use the famous NextAuth (Now Auth.js!) for our auth needs.

Before we begin, though, let's talk about how exactly we plan to solve the realtime data problem if we can't use WebSockets.

Live Queries on the Server - The Secret Sauce

The GraphQL specification defines Subscriptions - you set up a stateful WebSocket connection between the client and server, and then subscribe to an event on the server. When the server sees an event that matches a defined Subscription, it sends requested data over the WebSocket connection to the client - et voila, we have our data.

💡 This explanation is a little handwave-y, but bear with me. Going over the differences between the transports graphql-ws (GraphQL over WebSocket), graphql-helix (GraphQL over SEE) and @n1ru4l/socket-io-graphql-server (GraphQL over Socket.io) are a little beyond the scope of a tutorial.

When you can't use WebSockets (on serverless platforms, as mentioned before), you can't use Subscriptions…but that's where Live Queries come in.

They aren't part of the GraphQL spec, so exact definitions differ by client library, but essentially, unlike Subscriptions, Live Queries aim to subscribe to the current state of server data - not events (new payloads whenever the query would yield different data)...and unlike Subscriptions, when WebSocket connections aren't available, they can use plain old client-side HTTP polling at an interval, instead.

I can see readers picking up their pitchforks, right on cue.

Client-side polling for real-time data?! That's insanely expensive, and what about handling multiple clients querying the same data?

And you'd be right! But…that's where WunderGraph comes in. You see, we're not going to be polling this data client-side. Instead, we're pushing these Live Querying responsibilities onto Wundergraph, our Backend-for-Frontend, or API Gateway, whatever you want to call it.

WunderGraph is a dev tool that lets you define your data dependencies - GraphQL, REST APIs, Postgres and MySQL databases, Apollo Federations, and anything else you might think of - as config-as-code, and then it introspects them, turning all of it into a virtual graph that you can then query and mutate via GraphQL, and then expose as JSON-over-RPC for your client.

When you're making Live Queries from the WunderGraph layer, no matter how many users are subscribed to your live chat on the frontend, you'll only ever have a single polling instance active at any given time, for your entire app.

Much better than raw client-side polling; but is this perfect? No - the polling interval still adds latency and isn't ideal for apps that need truly instant feedback, and without a JSON patch, the entire result of a query will be sent over the wire to the client every single time, even the unchanged data - but for our use-case, this is just fine.

With all of that out of the way, let's get to the coding!

Architecture

First off, let's talk about the flow of this app.

image

Our users sign in with their GitHub account (via OAuth) for the chatroom. We're building a workgroup/internal team chat, and using GitHub as the provider for that makes sense.

Using NextAuth greatly streamlines our auth story - and it even has an official integration for Fauna - a serverless database! This makes the latter a really good pick as a database for both auth and business logic (storing chat messages).

Our Next.js frontend is relatively simple thanks to WunderGraph - it'll provide us with auto-generated (and typesafe!) querying and mutation hooks built on top of Vercel's SWR, and that's what our components will use to fetch (chat messages, and the list of online users) and write (new chat messages) data.

Let's get started!

Step 0: The Setup

NextJS + WunderGraph

WunderGraph's create-wundergraph-app CLI is the best way to set up both our BFF server and the Next.js frontend in one go, so let's do just that. Just make sure you have the latest Node.js LTS installed, first.

npx create-wundergraph-app my-project -E nextjs

Then, cd into the project directory, and

npm install && npm start

NextAuth

Our NextAuth setup involves a little busywork. Let's get the base package out of the way first.

npm install next-auth

Next, get the NextAuth adapter for FaunaDB. An “adapter” in NextAuth.js connects your application to whatever database or backend system you want to use to store data for users, their accounts, sessions, etc. Adapters are technically optional, but since we want to persist user sessions for our app, we'll need one.

npm install @next-auth/fauna-adapter faunadb

Finally, we'll need a “provider” - trusted services that can be used to sign in a user. You could define your own OAuth Provider if you wanted to, but here, we can just use the built-in GitHub provider.

Read the GitHub OAuth docs for how to register your app, configure it, and get the URL and Secret Key from your GitHub account (NOT optional). For the callback URL in your GitHub settings, use http://localhost:3000/api/auth/callback/github

Finally, once you have the GitHub client ID and secret key, put them in your Next.js ENV file (as GITHUB_ID and GITHUB_SECRET) respectively.

Step 1: The Data

Fauna is a geographically distributed (a perfect fit for the serverless/Edge era of Vercel and Netlify) document-relational database that aims to offer the best of both SQL (schema-based modeling) and NoSQL (flexibility, speed) worlds.

It offers GraphQL out of the box, but the most interesting feature about Fauna is that it makes it trivially easy to define stored procedures and expose them as GraphQL queries via the schema. (They call these User Defined Functions or UDFs). Very cool stuff! We'll make use of this liberally.

Sign up at Fauna, create a database (without sample data), note down your URL and Secret in your Next.js ENV (as FAUNADB_GRAPHQL_URL and FAUNADB_TOKEN respectively) and you can move on to the next step.

Auth

To use Fauna as our auth database, we'll need to define its Collections (think tables) and Indexes (all searching in Fauna is done using these) in a certain way. NextAuth walks us through this procedure here, so it's just a matter of copy-pasting these commands into your Fauna Shell.

First, the collections...

CreateCollection({ name: "accounts" });

CreateCollection({ name: "sessions" });

CreateCollection({ name: "users" });

CreateCollection({ name: "verification_tokens" });

...then the Indexes.

CreateIndex({
  name: "account_by_provider_and_provider_account_id",
  source: Collection("accounts"),
  unique: true,
  terms: [
    { field: ["data", "provider"] },
    { field: ["data", "providerAccountId"] },
  ],
});
CreateIndex({
  name: "session_by_session_token",
  source: Collection("sessions"),
  unique: true,
  terms: [{ field: ["data", "sessionToken"] }],
});
CreateIndex({
  name: "user_by_email",
  source: Collection("users"),
  unique: true,
  terms: [{ field: ["data", "email"] }],
});
CreateIndex({
  name: "verification_token_by_identifier_and_token",
  source: Collection("verification_tokens"),
  unique: true,
  terms: [{ field: ["data", "identifier"] }, { field: ["data", "token"] }],
});

Now that we have Fauna set up to accommodate our auth needs, go back to your project directory, and create a ./pages/api/auth/[...nextauth.ts] file with these contents:

import NextAuth from "next-auth";
import GithubProvider from "next-auth/providers/github";
import { Client as FaunaClient } from "faunadb";
import { FaunaAdapter } from "@next-auth/fauna-adapter";

const client = new FaunaClient({
  secret: process.env.FAUNA_SECRET,
  scheme: "https",
  domain: "db.fauna.com",
});

export const authOptions = {
  // Configure one or more authentication providers
  providers: [
    GithubProvider({
      clientId: process.env.GITHUB_ID,
      clientSecret: process.env.GITHUB_SECRET,
    }),
    // ...add more providers here
  ],
  adapter: FaunaAdapter(client),
};
export default NextAuth(authOptions);

That's it, we have some basic auth set up! We'll test this out in just a minute. But first…

Business Logic

Once we're done with Auth, it's time to define the GraphQL schema needed for our business logic, i.e users, chats, and sessions (with slight modifications to account for the existing NextAuth schema). Go to the GraphQL tab in your Fauna dashboard, and import this schema.

type users {
  name: String!
  email: String!
  image: String!
}

type chat {
  content: String!
  userId: String!
  timestamp: String!
}

type sessions {
  sessionToken: String!
  userId: String!
  expires: String!
}

type Query {
  allMessages: [chat!]
  allUsers: [users!]
  allSessions: [sessions!]
  userByUserID(userID: String!): users! @resolver(name: "getUserByUserID")
  userIDByEmail(email: String!): String! @resolver(name: "getUserIDByEmail")
}

See those last two queries marked with a @resolver directive? Those are the Fauna stored procedures/User Defined Functions we'll be creating! Go to the Functions tab and add these.

  1. getUserByUserID
Query(

	Lambda(

		["userID"],

		Select("data", Get(Ref(Collection("users"), Var("userID"))))

	)

)
  1. getUserIDByEmail
Query(

	Lambda(

		["email"],

		Select(["ref", "id"], Get(Match(Index("user_by_email"), Var("email"))))

	)

)

Being familiar with the FQL syntax helps, but these functions should be self explanatory - they do exactly what their names suggest - take in an argument, and look up value(s) that match it using the indexes defined earlier. When used with the @resolver directive in our schema, they are now exposed as GraphQL queries - incredibly useful. You could do almost anything you want with Fauna UDFs, and return whatever data you want.

Step 2: Data Dependencies and Operations

WunderGraph works by introspecting all data sources you define in a dependency array and building data models for them.

First, make sure your ENV file is configured properly…

GITHUB_ID="XXXXXXXXXXXXXXXX"

GITHUB_SECRET="XXXXXXXXXXXXXXXX"

FAUNA_SECRET="XXXXXXXXXXXXXXXX"

FAUNADB_GRAPHQL_URL="https://graphql.us.fauna.com/graphql"

FAUNADB_TOKEN="Bearer XXXXXXXXXXXXXXXX"

Replace with your own values, obviously. Also, double check to make sure the Fauna URL is set to the region you're hosting your instance in!

…and then, add our Fauna database as a dependency in WunderGraph, and let it do its thing.

const fauna = introspect.graphql({
	apiNamespace: 'db',
	url: new EnvironmentVariable('FAUNADB_GRAPHQL_URL'),
	headers: (builder) => {
	  builder.addStaticHeader(
		'Authorization',
		new EnvironmentVariable('FAUNADB_TOKEN')
	  )
	  return builder
	},
  })

// configureWunderGraph emits the configuration
configureWunderGraphApplication({
	apis: [fauna],
 ...
 })

You can then write GraphQL queries/mutations to define operations on this data (these go in the .wundergraph/operations directory), and WunderGraph will generate typesafe Next.js client hooks for accessing them.

  1. AllMessages.graphql
query FindAllMessages($userId: String! @internal) {
  db_allMessages {
    data {
      content
      timestamp
      userId @export(as: "userId")
      user: _join @transform(get: "db_userByUserID") {
        db_userByUserID(userID: $userId) {
          name
          email
          image
        }
      }
    }
  }
}

WunderGraph makes it trivial to perform JOINs using multiple queries - and using its _join directive (and @transform to cut down on unnecessary nesting), we can overcome the absence of traditional foreign keys and relations in Fauna - seen here while fetching the user linked with a given chat message by their userId.

  1. AllSessions.graphql
query AllSessions($userId: String! @internal) {
  db_allSessions {
    data {
      userId @export(as: "userId")
      user: _join @transform(get: "db_userByUserID") {
        db_userByUserID(userID: $userId) {
          name
          email
          image
        }
      }
    }
  }
}

This fetches all users who currently have an active session - we'll use this in our UI to indicate who are online. The way NextAuth works with GitHub is, it stores currently active sessions in the database, with an ‘expires' field. You can read the userId from the active sessions table, and whoever that userId belongs to can be considered to be currently online.

With a brief enough time-to-live for GitHub OAuth tokens, you will - usually - not have to worry about stale data when it comes to online users…and NextAuth is smart enough to invalidate stale tokens anyway when these users try to login with an expired token.

  1. UserByEmail.graphql
query UserByEmail($emailId: String!) {
  db_userIDByEmail(email: $emailId)
}
  1. AddMessage.graphql
mutation CreateChat($content: String!, $userId: String!, $timestamp: String!) {
  db_createChat(
    data: { content: $content, userId: $userId, timestamp: $timestamp }
  ) {
    _id
  }
}

And finally, this is our only mutation, triggered when the currently signed-in user sends a new message.

Step 3: The Root

_app.tsx

import Head from "next/head";
import Navbar from "../components/Navbar";
import "../styles/globals.css";
import { SessionProvider } from "next-auth/react";

function MyApp({ Component, pageProps: { session, ...pageProps } }) {
  return (
    <SessionProvider session={session}>
      <Head>
        <meta charSet="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      </Head>
      <header className="sticky top-0 z-50">
        <Navbar />
      </header>
      <main className="h-[calc(100vh-80px)] bg-gradient-to-b from-gray-700 to-gray-900">
        <Component {...pageProps} />
      </main>
    </SessionProvider>
  );
}

export default MyApp;

You'll have to wrap your app in to be able to use NextAuth's useSession hooks, by exposing the session context at the top level of your app.

Also I'm using TailwindCSS for styling - instructions for getting it set up with Next.js here.

index.tsx

import { NextPage } from "next";
import Chat from "../components/Chat";
import { withWunderGraph } from "../components/generated/nextjs";
import { useSession, signIn } from "next-auth/react";

const Home: NextPage = () => {
  const { data: session } = useSession();
  return (
    <div>
      {session ? (
        <div className="w-full h-[calc(100vh-85px)] ">
          <Chat />
        </div>
      ) : (
        <div className="w-full h-[calc(100vh-80px)] flex flex-col items-center justify-center bg-[radial-gradient(ellipse_at_right,_var(--tw-gradient-stops))] from-gray-700 via-gray-900 to-black p-4 ">
          <span className="text-white text-8xl font-semibold ">Hi!</span> <br />
          <span className="text-white text-lg">
            You need to be signed in to access our Workgroup chat.
          </span> <br />
          <button
            className=" bg-teal-500 hover:bg-teal-700 text-gray-800 font-bold py-2 px-4 rounded-full"
            onClick={() => signIn()}
          >
            Sign in
          </button>
        </div>
      )}
    </div>
  );
};

export default withWunderGraph(Home);

NextAuth's hooks make it trivially easy to implement authorization - the second part of auth. Use useSession to check if someone is signed in (this returns a user object with GitHub username, email, and avatar URL - nifty!), and the signIn and signOut hooks to redirect users to those pages automatically.

You can style custom NextAuth signIn/signOut pages yourself if you want (instructions here) but the default, unbranded styles work just fine for our needs.

Step 4: Online Users

./components/OnlineUsers.tsx

import { useQuery } from "../components/generated/nextjs";

const OnlineUsers = () => {
  const { data: onlineUsers } = useQuery({
    operationName: "AllSessions",
  });
  return (
    <div className="scrollbar scrollbar-thumb-black scrollbar-track-gray-100 h-full w-[20%] divide-y overflow-y-scroll bg-gray-900">
      {onlineUsers?.db_allSessions?.data?.map((user) => (
        <div className="flex w-full flex-row items-center p-2">
          <div className="h-[20px] w-[20px] rounded-[50%] bg-green-500"></div>
          <div key={user.userId} className="ml-2 py-2 font-bold text-white ">
            {user.user.name}
          </div>
        </div>
      ))}
    </div>
  );
};

export default OnlineUsers;

Nothing much to see here; our strategy for determining online users was mentioned in the GraphQL query for this already.

Step 5: The Chat Window (Feed and Input)

./components/ChatWindow.tsx

import React from "react";
/**
 * wundergraph stuff
 */
import {
  useQuery,
  useMutation,
  withWunderGraph,
} from "../components/generated/nextjs";
/**
 * nextauth stuff
 */
import { useSession } from "next-auth/react";
/**
 * nextjs stuff
 */
import Link from "next/link";
/**
 * my utility funcs
 */
import epochToTimestampString from "../utils/epochToTimestampString";

const ChatWindow = () => {
  /**
   * get current session data with nextauth
   *  */
  const { data: session } = useSession();
  /**
   * queries + mutations with WG
   */
  const { data: allMessages } = useQuery({
    operationName: "AllMessages",
    // liveQuery:true
  });
  const { data: currentUserID } = useQuery({
    operationName: "UserByEmail",
    input: {
      emailId: session.user.email,
    },
  });
  const {
    data: addedMessageID,
    error,
    trigger,
    isMutating,
  } = useMutation({
    operationName: "AddMessage",
  });
  /**
   * local state
   */
  const [submitDisabled, setSubmitDisabled] = React.useState < boolean > true;
  const [newMessage, setNewMessage] = React.useState < string > "";

  /**
   * event handlers
   */
  const handleSubmit = (event: React.FormEvent) => {
    event.preventDefault();
    //trigger mutation with current message, userid, and timestamp
    trigger({
      content: newMessage,
      userId: currentUserID?.db_userIDByEmail,
      timestamp: epochToTimestampString(
        Math.floor(new Date().getTime() / 1000.0)
      ),
    });
    // then reset message and redisable button
    setNewMessage("");
    setSubmitDisabled(true);
  };

  return (
    <div className="w-[80%] ">
      <div className="scrollbar scrollbar-thumb-teal-500 scrollbar-track-black h-[93%] w-full overflow-y-scroll bg-[radial-gradient(ellipse_at_right,_var(--tw-gradient-stops))]  from-gray-700 via-gray-900 to-black  p-4 ">
        {/* Chat messages go here */}
        {allMessages?.db_allMessages?.data.map((message) => (
          /* adjust alignment if current user */
          <div
            className={
              message.user?.email === session.user.email
                ? "my-4 ml-auto mr-2  flex w-fit max-w-md flex-col rounded-lg bg-zinc-200  px-4 py-2 text-gray-700"
                : "my-4 mr-auto ml-2 flex w-fit max-w-md flex-col rounded-lg  bg-gray-900 p-4 text-zinc-200  "
            }
          >
            <Link href={`https://www.github.com/${message.user?.name}`}>
              <span className="mb-2 cursor-pointer rounded-lg text-sm underline ">
                {message.user?.name}
              </span>
            </Link>

            <span className="font-bold ">{message.content}</span>

            <span
              className={`pt-2 text-right text-xs ${
                message.user?.email === session.user.email
                  ? "text-red-700"
                  : "text-teal-500"
              } mb-2 rounded-lg font-bold`}
            >
              {message.timestamp}
            </span>
          </div>
        ))}
      </div>
      {/* Input field for sending messages */}
      <div className="h-[7%] w-[98%] px-2 py-2">
        <form onSubmit={handleSubmit} className="relative rounded-md shadow-sm">
          <input
            type="text"
            value={newMessage}
            onChange={(event) => {
              setNewMessage(event.target.value);
              if (event.target.value.length > 0) {
                setSubmitDisabled(false);
              } else {
                setSubmitDisabled(true);
              }
            }}
            placeholder="Type your message here..."
            className="z-10 w-5/6 rounded-md border-[1px] bg-zinc-200 p-2 text-gray-900 focus:outline-none"
          />
          <button
            type="submit"
            className="z-20 w-1/6 rounded-r-full bg-teal-500 py-2 px-4 font-bold text-white hover:bg-teal-700 disabled:bg-teal-200 disabled:text-gray-500"
            disabled={submitDisabled || isMutating}
          >
            Send
          </button>
        </form>
      </div>
    </div>
  );
};

export default withWunderGraph(ChatWindow);

Here, we see how WunderGraph can turn any standard query into a Live Query with just one added option - liveQuery: true. To fine-tune polling intervals for Live Queries, though, check out ./wundergraph/wundergraph.operations.ts, and adjust this value in seconds.

queries: (config) => ({
  ...config,
  caching: {
    enable: false,
    staleWhileRevalidate: 60,
    maxAge: 60,
    public: true,
  },
  liveQuery: {
    enable: true,
    pollingIntervalSeconds: 1,
  },
  //...
});

For the timestamp, we'll use a simple utility function to get the current time in epoch - the number of seconds that have elapsed since January 1, 1970 (midnight UTC/GMT) - and converting it into human readable string to be stored in our database.

./utils/epochToTimestampString.ts

/*
Converts seconds to human readable date and time
*/
export default function epochToTimestampString(seconds: number): string {
  return new Date(seconds * 1000).toLocaleString();
}

Step 6 - The Navbar

import { useSession, signOut } from "next-auth/react";

const Navbar = () => {
  const { data: session } = useSession();
  return (
    <nav className="flex h-[80px] w-screen items-center justify-between border-b-2 border-gray-900 bg-black p-6">
      <div className="container flex min-w-full items-center justify-between pr-4">
        <div className="text-xl font-semibold tracking-tight text-white">
          #workgroup
        </div>

        <div className="flex items-center">
          {session && (
            <>
              <div className="mr-4 cursor-pointer tracking-tight text-teal-200">
                <span className="text-white ">@{session.user?.name}</span>
              </div>
              <div className="mr-4 cursor-pointer">
                <img
                  className="rounded-full"
                  src={session.user?.image}
                  height={"40px"}
                  width={"40px"}
                  alt={`Avatar for username ${session.user?.name}`}
                />
              </div>
              <div
                className="cursor-pointer tracking-tight text-teal-200 hover:bg-gray-800 hover:text-white"
                onClick={() => signOut()}
              >
                Logout
              </div>
            </>
          )}
        </div>
      </div>
    </nav>
  );
};

export default Navbar;

The NavBar component uses NextAuth's useSession hooks again - first to get the current user's GitHub name and avatar URL and render them, and signOut to…well, sign out.

That's All, Folks!

Fire up your browser, head on over to localhost:3000, and try it out. For screenshots here, I'm using two GitHub accounts on two different browsers, but I've tested this with up to five users and it works great. Here are some things you should note, though, going forward:

  1. Leave liveQuery: true commented out while you're building the UI, and only turn it on while testing the chat features. You don't want to thrash the Fauna server with calls - especially if on the limited free-tier!

  2. If you make changes to your Fauna GraphQL Schema during development, you'll probably find that WunderGraph's introspection doesn't catch the new changes. This is intentional - it builds a cache after first introspection, and works off of that instead because you don't want WunderGraph (which, in production, you'd deploy on Fly.io or WunderGraph Cloud) to waste resources doing full introspections every single time.

    To get around this, run npx wunderctl generate –-clear-cache if you've made changes to your database schema during development and want the models and hooks regenerated.

  3. When deploying your site, set the NEXTAUTH_URL environment variable to the canonical URL of the website.

Wishing you latency-free coding!




Continue Learning