Build a modern authenticated chat application with Next.js, Ably, and Clerk

Category
Guides
Published

Learn how to build a modern, authenticated chat application using Next.js, Ably, and Clerk. This comprehensive guide covers everything from setting up real-time messaging and user authentication to implementing roles and message history.

I’ll guide you through the process of creating an authenticated chat application with Next.js (app router). This application will incorporate a moderator role, allowing users to access restricted channels and delete inappropriate messages.

Follow along, and by the end of this tutorial, you'll have developed a sleek, modern chat application you can deploy on Vercel. It will feature a channel list, message transmission over WebSockets, and a dynamic “who's online” list that updates in realtime.

We'll use React on the frontend, incorporating intermediate features like the useReducer hook to keep the code clean. For styling, we'll leverage Tailwind and shadcn/ui. We'll integrate Ably for serverless WebSockets and Clerk for authentication and user management.

While this page guides you to a specific destination, along the way, you’ll learn the fundamentals of authentication and access control in Next.js. You can take the fundamental knowledge you learn here and use it to enable authentication and access control in your future projects.

Just want the code?

I feel that! You can find the complete source code on my GitHub.

If you want to run the project locally, I’ve included the minimal necessary steps in the repository README for you to reference.

Before you begin (prerequisites)

Before delving in, it would be good if you had an understanding of the following technologies and concepts:

  • React
    • Components and how to think in React
    • Strong grasp of useState and how to, for example, implement a form component from scratch
    • Familiarity with useEffect, useRef, and useReducer
  • Next.js
    • How to define routes with app router
    • Awareness of client components vs server components
  • CSS
    • Basic properties and values
    • Awareness of Flexbox and Grid

Start here

First, let’s create a blank Next.js project called chat-tutorial.

The easiest way is by using create-next-app. This command-line tool enables us to quickly start building a new Next.js application with the app router and Tailwind set up.

Run this command to create a Next.js app in a directory called chat-tutorial:

terminal
npx create-next-app chat-tutorial --javascript --tailwind --eslint --app --src-dir --no-import-alias

Let’s cd into it:

terminal
cd chat-tutorial

Create the chat layout

Before we go too far, let’s first create the layout for our chat application.

Open the chat-tutorial directory in your editor of choice and add a nav to src/app/layout.js:

src/app/layout.js
import { Inter } from 'next/font/google'
import './globals.css'

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

export const metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
}

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <nav className="flex border-b border-gray-200 p-5">
          <h1 className="font-bold">Clover Corp</h1>
        </nav>
        {children}
      </body>
    </html>
  )
}

Then, create src/app/chat/[[...channelName]]/page.js:

src/app/chat/[[...channelName]]/page.js
'use client'

const Page = ({ params }) => {
  return (
    <div className="grid h-[calc(100vh-72.8px)] grid-cols-4">
      <div className="border-r border-gray-200 p-5"></div>
      <div className="col-span-2"></div>
      <div className="border-l border-gray-200 p-5"></div>
    </div>
  )
}
export default Page

Run the development server with npm run dev, open your browser, then navigate to /chat to observe the three-column skeleton:

In the next steps, we’ll populate each column with the channel list, chat, and online list components respectively.

Note that /chat is a dynamic route with an optional route segment called channelName .

We’ll reference this by params.channelName and use the value do determine what channel the user is participating in.

Routeparams.channelName
/chat/announcements"announcements"
/chat/general"general"
/chat/random"random"
/chat/mods-only"mods-only"
/chatnull

Install shadcn/ui

If you haven’t come across shadcn/ui, this tool enables design-impaired JavaScript developers like me to pick and choose from an assortment of beautiful React components that are also accessible and easy to customize.

Still inside the chat-tutorial directory, run the following command:

terminal
npx shadcn-ui@latest init

You’ll be presented with 3 prompts. Answer like this:

  • Which style would you like to use? Default
  • Which color would you like to use as base color? Slate
  • Would you like to use CSS variables for colors? Yes

Build the chat React components

In this section, we’ll build MessageInput and MessageList components.

To make them look sleek without writing much code, we’ll lean on the shadcn/ui’s Input and Avatar components.

To create them, run the following commands:

terminal
npx shadcn-ui@latest add input
npx shadcn-ui@latest add avatar

Then, create src/app/[[...channel]]/chat/message-input.js:

src/app/[[...channel]]/chat/message-input.js
import { useState } from 'react'
import { Input } from '@/components/ui/input'

const MessageInput = ({ onSubmit, disabled }) => {
  const [input, setInput] = useState('')

  const handleChange = (e) => {
    setInput(e.target.value)
  }

  const handleSubmit = (e) => {
    e.preventDefault()
    onSubmit(input)
    setInput('')
  }

  return (
    <form onSubmit={handleSubmit}>
      <Input
        type="text"
        value={input}
        onChange={handleChange}
        disabled={disabled}
        placeholder={disabled ? 'This input has been disabled.' : 'Your message here'}
      />
    </form>
  )
}
export default MessageInput

Finally, create src/app/[[...channel]]/chat/message-list.js:

src/app/[[...channel]]/chat/message-list.js
import { Avatar, AvatarImage } from '@/components/ui/avatar'

const MessageList = ({ messages }) => {
  const createLi = (message) => (
    <li key={message.id} className="bg-slate-50 group my-2 flex justify-between p-3">
      <div className="flex items-center">
        <Avatar className="mr-2">
          <AvatarImage src={message.data.avatarUrl} />
        </Avatar>
        <p>{message.data.text}</p>
      </div>
    </li>
  )

  return <ul>{messages.map(createLi)}</ul>
}
export default MessageList

Realtime messaging with Ably

To enable realtime messaging and store message history, we’ll use a serverless WebSockets platform called Ably.

Because Ably manages the WebSockets, the connection is highly reliable. Additionally, using a hosted WebSocket platform like Ably allows you to benefit from realtime communication, even when deploying to serverless platforms such as Vercel that typically do not support persistent WebSocket connections.

Clients connect to Ably then publish and subscribe events in realtime using a persistent WebSocket connection under the hood.

With Ably, events are published to "topics" (named logical channels). Subscribers receive all messages published to the topics to which they subscribe.

Conceptually, this maps very tidily in chat application where we utilize an Ably topic per chat channel.

When the user navigates routes, the channelName segment is updated. We reference channelName to connect to an Ably topic by the same name prefixed with chat:, like so:

Routeparams.channelNameAbly topic
/chat/announcements"announcements""chat:announcements"
/chat/general"general""chat:general"
/chat/random"random""chat:random"
/chat/mods-only"mods-only""chat:mods-only"
/chatnullnull

The prefix part of the Ably topic is called a namespace. Namespaces allow us to logically group related channels and will become handy later on when we create rules for all chat:* channels.

The prefix chat: is one I chose arbitrarily and doesn’t mean anything specific in Ably.

Implement basic Ably chat messaging

If you haven’t already, sign up to Ably then Create new app with the following options:

  • App name: Call your app something meaningful
  • Select your preferred language(s): JavaScript
  • What type of app are you building? Live Chat

In the next screen, copy or otherwise note your Ably API key - we’ll need it in just a moment:

Back in the terminal, install the Ably JavaScript/React SDK with the following command:

terminal
npm install ably

After that, create src/app/[[...channel]]/chat/chat.js:

src/app/[[...channel]]/chat/chat.js
import MessageInput from './message-input'
import MessageList from './message-list'

import { useReducer } from 'react'
import { useChannel } from 'ably/react'

const ADD = 'ADD'

const reducer = (prev, event) => {
  switch (event.name) {
    // 👉 Append the message to messages
    case ADD:
      return [...prev, event]
  }
}

const Chat = ({ channelName }) => {
  // 👉 Placeholder user to be replaced with the authenticated user later
  const user = {
    imageUrl: 'https://ui-avatars.com/api/?name=Alex',
  }
  const [messages, dispatch] = useReducer(reducer, [])
  // 👉 useChannel accepts the channel name and a function to invoke when
  //    new messages are received. We pass dispatch.
  const { channel, publish } = useChannel(channelName, dispatch)

  const publishMessage = (text) => {
    // 👉 Publish event through Ably
    publish({
      name: ADD,
      data: {
        text,
        avatarUrl: user.imageUrl,
      },
    })
  }

  return (
    <>
      <div className="overflow-y-auto p-5">
        <MessageList messages={messages} />
      </div>
      <div className="mt-auto p-5">
        <MessageInput onSubmit={publishMessage} />
      </div>
    </>
  )
}
export default Chat

Finally, update src/app/[[...channel]]/chat/page.js.

Take care to replace YOUR_ABLY_API_KEY with the API key you noted a moment ago:

src/app/[[...channel]]/chat/page.js
'use client'

import Chat from './chat'
import { Realtime } from 'ably'
import { AblyProvider, ChannelProvider } from 'ably/react'

const Page = ({ params }) => {
  // 👉 Instantiate Ably client
  const client = new Realtime({
    key: 'YOUR_ABLY_API_KEY',
    clientId: 'Alex',
  })
  const channelName = `chat:${params.channel}`

  return (
    // 👉 Wrap chat app in AblyProvider and ChannelProvider necessary to
    // use Ably hooks
    <AblyProvider client={client}>
      <ChannelProvider channelName={channelName}>
        <div className="grid h-[calc(100vh-72.8px)] grid-cols-4">
          <div className="border-r border-gray-200 p-5"></div>
          <div className="col-span-2">
            <Chat channelName={channelName} />
          </div>
          <div className="border-l border-gray-200 p-5"></div>
        </div>
      </ChannelProvider>
    </AblyProvider>
  )
}
export default Page

With that, the basic chat is ready to test.

Open /chat/general in a couple of browswer windows to send and receive chat messages over WebSockets:

Before we go any further, there is something very important to note about the code above!

In this excerpt, we instantiate an Ably Realtime instance with the API key. We also hard-coded the clientId.

src/app/[[...channel]]/chat/page.js
const client = new Realtime({
  key: 'SlyWSw.jiu7RA:A64U3y3ty-9QLGB4Tn4NbBtlYYsx60qCI0ooT4WhNq0',
  clientId: 'Alex',
})
const channelName = `chat:${params.channel}`

This is problematic:

  • An Ably API key is secret and should never (ever) go in production client-side code as a malicious actor could easily find it and exploit your application.
  • Every client should have a unique identifier based on the user’s name.

In an upcoming section, we will address these concerns by implementing a secure backend endpoint to generate an Ably-compatible JWT token which includes verified information about the user’s identity and their capabilities.

Implement a chat channel-switcher

To help users discover channels and navigate them more easily, let’s create a ChannelList component and slot it into the left-hand column of our layout.

First, run this command to install clsx, a handy module for conditionally applying Tailwind styles:

terminal
npm install clsx

Next, create src/app/[[...channel]]/chat/channel-list.js:

src/app/[[...channel]]/chat/channel-list.js
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { clsx } from 'clsx'

const ChannelList = ({ channels }) => {
  const currentPath = usePathname()

  const createLi = (channel) => {
    return (
      <li key={channel.path}>
        <Link
          href={channel.path}
          className={clsx('flex items-center', {
            'font-bold': currentPath === channel.path,
          })}
        >
          {channel.label}
        </Link>
      </li>
    )
  }

  return <ul>{channels.map(createLi)}</ul>
}

export default ChannelList

Finally, update src/app/[[...channel]]/chat/page.js:

src/app/[[...channel]]/chat/page.js
'use client'
import Chat from './chat'
import ChannelList from './channel-list'
import { Realtime } from 'ably'
import { AblyProvider, ChannelProvider } from 'ably/react'

const Page = ({ params }) => {
  const channels = [
    { path: '/chat/announcements', label: '# Announcements' },
    { path: '/chat/general', label: '# General' },
    { path: '/chat/random', label: '# Random' },
    { path: '/chat/mods-only', label: '# Mods-only', modOnly: true },
  ]

  const client = new Realtime({
    key: 'SlyWSw.jiu7RA:A64U3y3ty-9QLGB4Tn4NbBtlYYsx60qCI0ooT4WhNq0',
    clientId: 'Alex',
  })
  const channelName = `chat:${params.channel}`

  return (
    <AblyProvider client={client}>
      <ChannelProvider channelName={channelName}>
        <div className="grid h-[calc(100vh-72.8px)] grid-cols-4">
          <div className="border-r border-gray-200 p-5">
            <ChannelList channels={channels} />
          </div>
          <div className="col-span-2">
            <Chat channelName={channelName} />
          </div>
          <div className="border-l border-gray-200 p-5"></div>
        </div>
      </ChannelProvider>
    </AblyProvider>
  )
}
export default Page

Using clsx, we conditionally bolden the current channel to convey where they’re currently chatting.

Here I hardcoded a channels list but this same data structure could come from a database, should you wish to allow users to create channels dynamically.

Return to the browser and check it out - the user can now switch channels without fumbling around in the address bar:

Authentication with Clerk

With basic realtime messaging in place, it’s time to authenticate Next.js users. We will use Clerk.

Clerk is an authentication and user management platform with ready-made React components and seamless Next.js authentication middleware.

With Clerk, you get an assortment of well-designed React components. Some are UI components like SignInButton and UserButton, while others are helper components that conditionally render children based on some authentication state.

It’s typical to use both kinds, and they make adding fully-featured authentication to Next.js a lot quicker than you might be expecting if you’ve previously rolled your own auth.

In addition to authentication, Clerk handles user management. This means when a user signs up to your application, Clerk stores and manages a user record with information such as the their full name, avatar, and metadata.

In the next sections, we’ll utilize this information from Clerk to identify the Ably client by the user’s unique identifier instead of the hardcoded value we’ve relied on previously.

The Clerk dashboard UI provides a convenient way to administer user’s information, such as their role, without having to implement commands or a custom backend UI.

For the purposes of this tutorial, we’ll update the user’s public metadata with an isMod property that denotes the user’s role in the chat application (more on that soon).

Configure Clerk

If you haven’t already, create a Clerk account. Clerk doesn’t ask for a credit card and you get 10,000 monthly active users for free.

Next, create a Clerk application with username, phone, and email sign in options:

Still in the Clerk dashboard, under User & Authentication and Email, Phone, Username, scroll to Personal information and enable Name:

Under Customization and Avatars, enable Initials:

If a user doesn’t have an avatar, Clerk will generate one based on their initials and your configured aesthetic. I set the background to Solid and chose the color #BDBDBD but, please, feel free to make it your own!

Note

If you enabled social sign in options like GitHub earlier and sign in with them, Clerk will pull the avatar from the there by default.

Users can also upload a custom avatar through the <UserButton />.

Next, under API Keys, take advantage of the handy Quick Copy box to copy your Clerk environment variables.

Create a file called ./.env.local and paste them in.

It’ll look something like this:

.env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY
CLERK_SECRET_KEY=YOUR_SECRET_KEY

Implement authentication with Clerk

To start authenticating Next.js users with Clerk, we’ll first need to install the Clerk Next SDK by running this command:

terminal
npm install @clerk/nextjs

Next, create src/middleware.js:

src/middleware.js
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'

const isChatRoute = createRouteMatcher(['/chat(.*)'])

export default clerkMiddleware((auth, req) => {
  if (isChatRoute(req)) auth().protect()
})

export const config = {
  matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
}

This middleware makes it so that only authenticated users can access routes starting with /chat. If the user isn’t yet authenticated, they’ll be redirected to the Clerk account portal to sign up or sign in.

Next, update src/app/layout.js:

src/app/layout.js
import { Inter } from 'next/font/google'
import {
  ClerkLoaded,
  ClerkProvider,
  SignInButton,
  SignedIn,
  SignedOut,
  UserButton,
} from '@clerk/nextjs'

import './globals.css'

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

export const metadata = {
  title: 'Comet',
}

export default function RootLayout({ children }) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body className={inter.className}>
          <nav className="flex justify-between border-b border-gray-200 p-5">
            <h1 className="font-bold">Comet</h1>
            <SignedOut>
              <SignInButton mode="modal" />
            </SignedOut>
            <SignedIn>
              <UserButton showName afterSignOutUrl="/" />
            </SignedIn>
          </nav>

          {children}
          <ClerkLoaded>{children}</ClerkLoaded>
        </body>
      </html>
    </ClerkProvider>
  )
}

Here, we utilize some of those Clerk React components I mentioned before:

  • The ClerkProvider component wraps your React application to provide active session and user context to Clerk's hooks and other components.
  • <SignInButton>  is a button that links to the sign-in page or displays the sign-in modal.
  • <UserButton>  is used to render the user’s avatar and a drop-down to manage their account and session.
  • <SignedIn> offers authentication checks as a cross-cutting concern. Any children components wrapped by a <SignedIn> component will be rendered only if there's a User with an active Session signed in your application.
  • <SignedOut>  is opposite of <SignedIn>.
  • The <ClerkLoaded> component guarantees that the Clerk object has loaded and will be available under window.Clerk. This allows you to wrap child components to access the Clerk object without the need to check it exists.

Let’s see it all in action.

Open /chat/general and you’ll be redirected to the Clerk account portal to sign in.

Sign in successfully and Clerk will redirect the user back to /chat/general where they’ll have full access to the chat.

Note the UserButton in the top-right corner, designed to resemble the user button UI popularized by Google:

Using Ably and Clerk together

So far, we have a functioning chat application and we have a way to authenticate users but these two parts of the app aren’t really connected:

  • Even though the user’s signed in under their own name, every message is still technically coming from “Alex” with the “AL” avatar we hard-coded earlier. Now that we know the identity of the user, we’ll need to pass that on to Ably instead of the hard-coded value.
  • Even though the Next.js pages are protected behind a sign in form, the underlying Ably topic is technically accessible to anyone. That isn’t necessarily a problem for public channels like “general”, but the “mods-only” channel includes sensitive messages and access should be carefully restricted. Now that we have a means to authenticate users with Clerk, let’s authorize clients them before allowing them to connect to restricted channels.

As a reminder, earlier, we hard-coded the Ably API key in the client-side code:

src/app/[[...channel]]/chat/page.js
const client = new Realtime({
  key: 'SlyWSw.jiu7RA:A64U3y3ty-9QLGB4Tn4NbBtlYYsx60qCI0ooT4WhNq0',
  clientId: 'Alex',
})

This made it convenient to connect to Ably for the purposes of this tutorial, but it’s neither secure nor flexible.

In the upcoming section, we’ll remove the hard-coded values. Instead, we’ll point Ably at an authentication backend endpoint poised to return an Ably-compatible JWT token.

Before we write any code on that front, let’s take a moment to understand Ably-compatible JWT tokens and the token flow at high level. It will make the code in the next section a lot easier to understand.

An Ably-compatible JWT token contains three crucial pieces of information:

  1. The identity (clientId) of the authenticated user (according to Clerk and the Clerk Next.js middleware)
  2. The capabilities this user has in what Ably channels, dependent on their role
  3. Additional claims, such as isMod

In the next section, we will implement the backend endpoint that builds a token based on the user’s role.

Instead of passing a hard-coded clientId and key to Ably, we’ll point the Ably JavaScript SDK to the backend endpoint we’re about to implement.

Equipped with the backend authentication endpoint, the Ably SDK will automatically request the token from the backend endpoint before using it to connect to the service.

Ably uses this token for two discrete purposes:

  • Capabilities: A token contains information about the user’s Ably capabilities such as their capability to subscribe to messages in a topic named "chat:mods-only”. If the user doesn’t have the capability to a restricted topic like “chat:mods-only”, Ably rejects the connection and the Ably client propagates a connection error.
  • Claims: A token also includes claims. When Ably learns of a client’s claims, the service will automatically add those claims to the metadata of events published by the authenticated client. These claims can be read by the client to authorize actions such as deleting or editing a message.

While capabilities and claims can sound similar, they are two discrete ideas in the Ably world.

We use capabilities to allow or disallow native Ably operations like subscribing or publishing to/on a specific topic or set of topics.

Capabilities, on the other hand, are metadata encoded in the token and associated with the connected client and any events they subsequently publish. They are used to authorize bespoke functionality like message deletions or edits.

Create an Ably token Next endpoint

First things first, copy the Ably API key from earlier into .env.local. You can copy it over from src/app/[[...channel]]/chat/page.js or fetch it from the Ably dashboard again:

.env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=YOUR_PUBLISHABLE_KEY
CLERK_SECRET_KEY=YOUR_SECRET_KEY
ABLY_SECRET_KEY=XXXXXXX

Going forward, we’ll only reference the key from here (we’ll remove it from the client-side code in due course).

Next, install jose, a JWT helper library:

terminal
npm install jose

Once jose installs, create src/app/api/ably/route.js:

src/app/api/ably/route.js
import { currentUser } from '@clerk/nextjs/server'
import { SignJWT } from 'jose'

const createToken = (clientId, apiKey, claim, capability) => {
  const [appId, signingKey] = apiKey.split(':', 2)
  const enc = new TextEncoder()
  const token = new SignJWT({
    'x-ably-capability': JSON.stringify(capability),
    'x-ably-clientId': clientId,
    'ably.channel.*': JSON.stringify(claim),
    // 'ably.limits.publish.perAttachment.maxRate.chat': 0.1,
  })
    .setProtectedHeader({ kid: appId, alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('24h')
    .sign(enc.encode(signingKey))
  return token
}

const generateCapability = (claim) => {
  if (claim.isMod) {
    return { '*': ['*'] }
  } else {
    return {
      'chat:general': ['subscribe', 'publish', 'presence', 'history'],
      'chat:random': ['subscribe', 'publish', 'presence', 'history'],
      'chat:announcements': ['subscribe', 'presence', 'history'],
    }
  }
}

export const GET = async () => {
  const user = await currentUser()

  const userClaim = user.publicMetadata
  const userCapability = generateCapability(userClaim)

  const token = await createToken(user.id, process.env.ABLY_SECRET_KEY, userClaim, userCapability)

  return Response.json(token)
}

Here, we utilize Clerk’s currentUser function to access information about the authenticated user, including their ID. This user ID is used for the Ably clientId, ensuring every Ably client has a unique identifier.

If the user is a moderator according to the user’s publicMetadata we set in the Clerk dashboard, we give them unrestricted Ably capabilities. Otherwise, they are limited to 3 channels:

src/app/api/ably/route.js
  if (claim.isMod) {
    return { '*': ['*'] };
  } else {
    return {
      'chat:general': ['subscribe', 'publish', 'presence', 'history'],
      'chat:random': ['subscribe', 'publish', 'presence', 'history'],
      'chat:announcements': ['subscribe', 'presence', 'history']
    }
  }
}

What’s more, we encode the “isMod” claim in the token:

src/app/api/ably/route.js
const token = new SignJWT({
  'x-ably-capability': JSON.stringify(capability),
  'x-ably-clientId': clientId,
  'ably.channel.*': JSON.stringify(claim),
  // 'ably.limits.publish.perAttachment.maxRate.chat': 0.1,
})
  .setProtectedHeader({ kid: appId, alg: 'HS256' })
  .setIssuedAt()
  .setExpirationTime('24h')
  .sign(enc.encode(signingKey))

Ably will include this claim in any events published by this client in topics with a name matching * . The * character is a wildcard, which Ably understands to mean the claim should be included in any event on any topic.

It’s important to note that, after building up the token, we ultimately sign the token with ABLY_SECRET_KEY before returning it to the client.

Ably uses this same key to cryptographically verify the integrity of the token on the receiving end, ensuring the token was issued by a secure server (ours) and hasn’t been tampered with. Coincidentally, this highlights just how important it is to keep your secret keys secret!

Next, update src/app/chat/[[...channelName]]/page.js to replace the hard-coded values with a link to the backend authUrl:

src/app/chat/[[...channelName]]/page.js
'use client'
import Chat from './chat'
import ChannelList from './channel-list'
import { Realtime } from 'ably'
import { AblyProvider, ChannelProvider } from 'ably/react'

const Page = ({ params }) => {
  const channels = [
    { path: '/chat/announcements', label: '# Announcements' },
    { path: '/chat/general', label: '# General' },
    { path: '/chat/random', label: '# Random' },
    { path: '/chat/mods-only', label: '# Mods-only', modOnly: true },
  ]

  const client = new Realtime({
    key: 'SlyWSw.jiu7RA:A64U3y3ty-9QLGB4Tn4NbBtlYYsx60qCI0ooT4WhNq0',
    clientId: 'Alex',
    authUrl: '/api/ably',
    autoConnect: typeof window !== 'undefined',
  })
  const channelName = `chat:${params.channel}`

  return (
    <AblyProvider client={client}>
      <ChannelProvider channelName={channelName}>
        <div className="grid h-[calc(100vh-72.8px)] grid-cols-4">
          <div className="border-r border-gray-200 p-5">
            <ChannelList channels={channels} />
          </div>
          <div className="col-span-2">
            <Chat channelName={channelName} />
          </div>
          <div className="border-l border-gray-200 p-5"></div>
        </div>
      </ChannelProvider>
    </AblyProvider>
  )
}
export default Page

Finally, update src/app/[[...channel]]/chat/chat.js:

src/app/[[...channel]]/chat/chat.js
import MessageInput from './message-input'
import MessageList from './message-list'
import { useReducer } from 'react'
import { useChannel } from 'ably/react'
import { useUser } from '@clerk/nextjs'

const ADD = 'ADD'

const reducer = (prev, event) => {
  switch (event.name) {
    case ADD:
      return [...prev, event]
  }
}

const Chat = ({ channelName }) => {
  const user = {
    imageUrl: '',
  }
  const { user } = useUser()
  const [messages, dispatch] = useReducer(reducer, [])
  const { channel, publish } = useChannel(channelName, dispatch)

  const publishMessage = (text) => {
    publish({
      name: ADD,
      data: {
        text,
        avatarUrl: user.imageUrl,
      },
    })
  }

  return (
    <>
      <div className="overflow-y-auto p-5">
        <MessageList messages={messages} />
      </div>
      <div className="mt-auto p-5">
        <MessageInput onSubmit={publishMessage} />
      </div>
    </>
  )
}
export default Chat

Reload the page and instead of the “AB” initials I hard-coded earlier, you should see your own!

Lock moderator-only channels

The moderator-only channel isn’t accessible to unauthorized users, yet, the user interface makes it look clickable.

It would be a surprising experience if your user clicks the channel only to get an Ably error because they don’t have the necessary capabilities based on their role.

In this section, let’s quickly disable moderator-only channels and show a lock icon if the user isn’t authorized to participate.

First, install the lucide-react icon library by running the following command:

terminal
npm install lucide-react

This is where we’ll get the lock icon from.

Next, update src/app/[[...channel]]/chat/channel-list.js:

src/app/[[...channel]]/chat/channel-list.js
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { clsx } from 'clsx'
import { useUser } from '@clerk/nextjs'
import { Lock } from 'lucide-react'

const ChannelList = ({ channels }) => {
  const currentPath = usePathname()
  const { user } = useUser()
  const userIsMod = user?.publicMetadata.isMod

  const createLi = (channel) => {
    const locked = channel.modOnly && !userIsMod
    return (
      <li key={channel.path}>
        <Link
          href={channel.path}
          className={clsx('flex items-center', {
            'font-bold': currentPath === channel.path,
            'pointer-events-none': locked,
          })}
        >
          {channel.label}
          {locked && <Lock className="m-1" size={16} />}
        </Link>
      </li>
    )
  }

  return <ul> {channels.map(createLi)} </ul>
}

export default ChannelList

Reload the page and you should see that the mods-only channel is “locked” and unclickable:

To really test if this works, we’ll need to grant our user moderator permissions. If all is working well, the channel should become “unlocked” and accessible.

Managing moderator roles with Clerk

To promote your user to a moderator, open the Clerk Users tab in the dashboard and find your user.

If you’ve been making lots of test accounts like I sometimes do, a good tip is to sort by Last signed in:

Scroll to the bottom and Edit the public metadata to look like this:

Hit Save, then reload your app for good measure.

Your user and fellow moderators will now have access to the channel:

As an exercise, I like the idea of adding a /promote {user} command, similar to what you might find in Discord, say.

That would be totally doable on the backend using updateUserMetadata, but I’ll leave it as a stretch goal for you should you like the sound of this challenge!

Implement message deleting

Next, let’s enable users to delete messages.

Our requirements are as follows:

  • Any user should be able to delete their own message (we all make typos).
  • Meanwhile, moderators should have permission to delete anyone’s messages.

Ably doesn’t support deleting messages in the traditional sense, however, the service supports message interactions, which allow you to associate metadata such as “deleted” with a previously-sent message. This is known as a soft delete.

In the next section, we’ll add a “delete message” button but, before implementing the code, I should explain how we plan to authorize message deletions. This will make the code in the next section a lot easier to understand.

At a high level, a message deletion is a special type of Ably event called a message interaction.

When the user click the “delete message” button, we’ll publish an Ably message interaction called “delete” with an extras property that references the message they’re trying to delete’s identifier called a timeserial.

Here’s a preview of code (you’ll see where to slot it in the next section):

Publishes a delete Ably message interaction
const deleteMessage = (timeSerial) => {
  publish({
    name: DELETE,
    extras: {
      ref: {
        timeserial: timeSerial,
      },
    },
  })
}

Subscribers receive this event, at which point, we will execute some logic to process the message deletion:

Subscribes to a delete Ably message interaction
const reducer = (prev, event) => {
  switch (event.name) {
    case ADD:
      return [...prev, event]
    case DELETE:
      const isMod = JSON.parse(event.extras.userClaim).isMod
      return prev.filter((msg) => {
        const match = msg.extras.timeserial === event.extras.ref.timeserial
        const ownMsg = msg.clientId === event.clientId
        if (match && (ownMsg || isMod)) {
          return false
        }
        return true
      })
  }
}
  • If the message identifier matches the deleted messages identifier, we move on to check if the user is allowed to delete it.
  • If the message-to-delete was sent by the same client that published the delete event, we know they’re allowed to delete it so we remove it from the messages state.
  • Otherwise, they might be a moderator, so we check if the delete event has the isMod claim and, if so, proceed to remove the message from the messages state.

As a reminder, if the user is a moderator, Ably will include the isMod claim in any event they publish.

It’s important to note that, while anyone can technically publish a delete message, we only process the deletion if the user owns the message or has the isMod claim. For anyone else who might publish a delete message, nothing happens, it has no effect.

With the theory out of the way (and a preview of the code), let’s tie it all together in the next section.

Implement a delete message button

First, create the shadcn/ui menubar component by running the following command, we’ll need it in a moment:

terminal
npx shadcn-ui@latest add menubar

Next, enable message interactions for your Ably app.

To do this, open your app, click Settings, then Add new rule. Enter the namespace “chat”, tick Message interactions enabled, then click Create channel rule.

Once you’ve done that, update src/app/[[...channel]]/chat/message-list.js:

src/app/[[...channel]]/chat/message-list.js
import { Avatar, AvatarImage } from '@/components/ui/avatar'
import { EllipsisVertical } from 'lucide-react'
import {
  Menubar,
  MenubarContent,
  MenubarItem,
  MenubarMenu,
  MenubarTrigger,
} from '@/components/ui/menubar'
import { useUser } from '@clerk/nextjs'

const userCanDelete = (message, user) => {
  return user.publicMetadata.isMod || message.clientId === user.id
}

const MessageList = ({ messages, onDelete }) => {
  const { user } = useUser()
  const createLi = (message) => (
    <li key={message.id} className="bg-slate-50 group my-2 flex justify-between p-3">
      <div className="flex items-center">
        <Avatar className="mr-2">
          <AvatarImage src={message.data.avatarUrl} />
        </Avatar>
        <p>{message.data.text}</p>
      </div>

      <Menubar>
        <MenubarMenu>
          <MenubarTrigger className="cursor-pointer">
            <EllipsisVertical size={16} />
          </MenubarTrigger>
          <MenubarContent>
            <MenubarItem
              disabled={!userCanDelete(message, user)}
              onClick={() => onDelete(message.extras.timeserial)}
            >
              Delete
            </MenubarItem>
          </MenubarContent>
        </MenubarMenu>
      </Menubar>
    </li>
  )

  return <ul> {messages.map(createLi)} </ul>
}
export default MessageList

Finally, update src/app/[[...channel]]/chat/chat.js:

src/app/[[...channel]]/chat/chat.js
import MessageInput from './message-input'
import MessageList from './message-list'
import { useReducer } from 'react'
import { useChannel } from 'ably/react'
import { useUser } from '@clerk/nextjs'

const ADD = 'ADD'
const DELETE = 'DELETE'

const reducer = (prev, event) => {
  switch (event.name) {
    case ADD:
      return [...prev, event]
    case DELETE:
      const isMod = JSON.parse(event.extras.userClaim).isMod
      return prev.filter((msg) => {
        const match = msg.extras.timeserial === event.extras.ref.timeserial
        const ownMsg = msg.clientId === event.clientId
        if (match && (ownMsg || isMod)) {
          return false
        }
        return true
      })
  }
}

const Chat = ({ channelName }) => {
  const { user } = useUser()
  const [messages, dispatch] = useReducer(reducer, [])
  const { channel, publish } = useChannel(channelName, dispatch)

  const publishMessage = (text) => {
    console.log('user', user)
    publish({
      name: ADD,
      data: {
        text,
        avatarUrl: user.imageUrl,
      },
    })
  }

  const deleteMessage = (timeSerial) => {
    publish({
      name: DELETE,
      extras: {
        ref: {
          timeserial: timeSerial,
        },
      },
    })
  }

  return (
    <>
      <div className="overflow-y-auto p-5">
        <MessageList messages={messages} />
        <MessageList messages={messages} onDelete={deleteMessage} />
      </div>
      <div className="mt-auto p-5">
        <MessageInput onSubmit={publishMessage} />
      </div>
    </>
  )
}
export default Chat

Reload the application. Assuming you’re logged into the same account and still have isMod , you will have the ability to Delete any message.

Remove the isMod role from your user via the Clerk dashboard and reload the page for good measure, and the Delete button will now be greyed out:

Implement chat message history

Ably is not a database, but it does hold on to events history for 24-72 hours (free accounts are limited to 24 hours).

You should ideally store chat messages in your own databases for long-term processing (either using an Ably queue or by subscribing to events on your server), but Ably history allows us to conveniently populate the UI with recent messages so that users have some context about the conversation they’re joining.

To enable message history, open your Ably app in the Ably dashboard, click Settings, spot the rule you created in the previous step for message interactions, click Edit, then tick Persist all messages. Finally, click Save.

Next, update src/app/[[...channel]]/chat/chat.js to fetch message history. While we’re here, we’ll also create a React hook that automatically scrolls messages into view:

src/app/[[...channel]]/chat/chat.js
import MessageInput from './message-input'
import MessageList from './message-list'
import { useReducer } from 'react'
import { useReducer, useEffect, useRef } from 'react'
import { useChannel } from 'ably/react'
import { useUser } from '@clerk/nextjs'

const ADD = 'ADD'
const DELETE = 'DELETE'

const reducer = (prev, event) => {
  switch (event.name) {
    case ADD:
      return [...prev, event]
    case DELETE:
      const isMod = JSON.parse(event.extras.userClaim).isMod
      return prev.filter((msg) => {
        const match = msg.extras.timeserial === event.extras.ref.timeserial
        const ownMsg = msg.clientId === event.clientId
        if (match && (ownMsg || isMod)) {
          return false
        }
        return true
      })
  }
}

const Chat = ({ channelName }) => {
  const { user } = useUser()
  const [messages, dispatch] = useReducer(reducer, [])
  const { channel, publish } = useChannel(channelName, dispatch)
  const scrollRef = useRef(null)

  const publishMessage = (text) => {
    console.log('user', user)
    publish({
      name: ADD,
      data: {
        text,
        avatarUrl: user.imageUrl,
      },
    })
  }

  const deleteMessage = (timeSerial) => {
    publish({
      name: DELETE,
      extras: {
        ref: {
          timeserial: timeSerial,
        },
      },
    })
  }
  useEffect(() => {
    let ignore = false
    const fetchHist = async () => {
      const history = await channel.history({ limit: 100, direction: 'forwards' })
      if (!ignore) history.items.forEach(dispatch)
    }
    fetchHist()
    return () => {
      ignore = true
    }
  }, [channel])

  useEffect(() => {
    scrollRef.current.scrollIntoView()
  }, [messages.length])

  return (
    <>
      <div className="overflow-y-auto p-5">
        <MessageList messages={messages} onDelete={deleteMessage} />
        <div ref={scrollRef} />
      </div>
      <div className="mt-auto p-5">
        <MessageInput onSubmit={publishMessage} />
      </div>
    </>
  )
}
export default Chat

Send a test message or two and reload the page. Whereas before, the chat start completely empty, the chat now loads with recent messages, allowing your users to find their place in the conversation.

Implement an online list

An online list creates a sense of togetherness among your users and subtly communicates who’s online and likely to respond.

Using Ably, we can implement such functionality using a feature called presence.

Presence provides information and realtime updates about who’s “present” on a particular topic.

First, create /src/app/chat[[...channel]]/whos-online-list.js:

/src/app/chat[[...channel]]/whos-online-list.js
'use client'
import { usePresence, usePresenceListener } from 'ably/react'
import { useUser } from '@clerk/nextjs'
import { Circle } from 'lucide-react'

const WhosOnlineList = ({ channelName }) => {
  const { user } = useUser()
  const { presenceData } = usePresenceListener(channelName)
  usePresence(channelName, { fullName: user.fullName })
  const users = presenceData
  const color = '#01FE19'

  const createLi = (user) => {
    return (
      <li key={user.id} className="flex items-center">
        <Circle className="mr-1" size={8} fill={color} color={color} />
        {user.data.fullName}
      </li>
    )
  }

  return (
    <div>
      <h2 className="mb-2.5">Present and together right now with you in {channelName}:</h2>
      <ul>{users.map(createLi)}</ul>
    </div>
  )
}
export default WhosOnlineList

Now, for the final time in this tutorial, update src/app/[[...channel]]/chat/page.js:

src/app/[[...channel]]/chat/page.js
'use client'
import Chat from './chat'
import ChannelList from './channel-list'
import WhosOnlineList from './whos-online-list'
import { Realtime } from 'ably'
import { AblyProvider, ChannelProvider } from 'ably/react'

const Page = ({ params }) => {
  const channels = [
    { path: '/chat/announcements', label: '# Announcements' },
    { path: '/chat/general', label: '# General' },
    { path: '/chat/random', label: '# Random' },
    { path: '/chat/mods-only', label: '# Mods-only', modOnly: true },
  ]

  const client = new Realtime({
    authUrl: '/api/ably',
    autoConnect: typeof window !== 'undefined',
  })
  const channelName = `chat:${params.channel}`

  return (
    <AblyProvider client={client}>
      <ChannelProvider channelName={channelName}>
        <div className="grid h-[calc(100vh-72.8px)] grid-cols-4">
          <div className="border-r border-gray-200 p-5">
            <ChannelList channels={channels} />
          </div>
          <div className="col-span-2">
            <Chat channelName={channelName} />
          </div>
          <div className="border-l border-gray-200 p-5">
            <WhosOnlineList channelName={channelName} />
          </div>
        </div>
      </ChannelProvider>
    </AblyProvider>
  )
}
export default Page

As users come and go, so does the green light that indicates their online status.

Here, we use presence on the topic for the user’s current chat channel, which means the online status is on a per-channel basis.

Alternatively, you could introduce a new topic like “clover-corp” (or whatever meaningful name you gave your app) and enable presence on a per-app basis instead. Remember, whenever you introduce a new Ably topic, you’ll need to introduce another ChannelProvider.

Conclusion

In this tutorial, we fleshed out a highly-featured authenticated Next.js chat application, featuring message deletion, online presence, and a moderator role!

If you found this guide useful, tell us how you used it in your app on Twitter/X, and be sure to tag @clerkdev to let us know!

Author
Alex Booker