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.
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fimage.png&w=3840&q=75)
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.
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2F1.png&w=3840&q=75)
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
, anduseReducer
- 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
:
npx create-next-app chat-tutorial --javascript --tailwind --eslint --app --src-dir --no-import-alias
Let’s cd
into it:
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
:
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
:
'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:
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fskeleton.png&w=3840&q=75)
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.
Route | params.channelName |
---|---|
/chat/announcements | "announcements" |
/chat/general | "general" |
/chat/random | "random" |
/chat/mods-only | "mods-only" |
/chat | null |
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:
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:
npx shadcn-ui@latest add input
npx shadcn-ui@latest add avatar
Then, create 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
:
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.
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fably.png&w=3840&q=75)
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.
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fpub-sub.png&w=3840&q=75)
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:
Route | params.channelName | Ably topic |
---|---|---|
/chat/announcements | "announcements" | "chat:announcements" |
/chat/general | "general" | "chat:general" |
/chat/random | "random" | "chat:random" |
/chat/mods-only | "mods-only" | "chat:mods-only" |
/chat | null | null |
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
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fcreate-ably-app.png&w=3840&q=75)
In the next screen, copy or otherwise note your Ably API key - we’ll need it in just a moment:
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fcopy-ably-key.png&w=3840&q=75)
Back in the terminal, install the Ably JavaScript/React SDK with the following command:
npm install ably
After that, create 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:
'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:
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Frealtime-demo.gif&w=3840&q=75)
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
.
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:
npm install clsx
Next, create 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
:
'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:
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fchannel-switcher.png&w=3840&q=75)
Authentication with Clerk
With basic realtime messaging in place, it’s time to authenticate Next.js users. We will use Clerk.
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fclerk.png&w=3840&q=75)
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.
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fclerk-feature-grid.png&w=3840&q=75)
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).
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fclerk-dashboard-preview.png&w=3840&q=75)
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:
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fcreate-clerk-app.png&w=3840&q=75)
Still in the Clerk dashboard, under User & Authentication and Email, Phone, Username, scroll to Personal information and enable Name:
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fenable-name.png&w=3840&q=75)
Under Customization and Avatars, enable Initials:
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fenable-initials.png&w=3840&q=75)
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!
Next, under API Keys, take advantage of the handy Quick Copy box to copy your Clerk environment variables.
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fquick-copy-envs.png&w=3840&q=75)
Create a file called ./.env.local
and paste them in.
It’ll look something like this:
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:
npm install @clerk/nextjs
Next, create 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
:
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 underwindow.Clerk
. This allows you to wrap child components to access theClerk
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.
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fsign-in.png&w=3840&q=75)
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:
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fuser-btn.png&w=3840&q=75)
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:
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:
- The identity (
clientId
) of the authenticated user (according to Clerk and the Clerk Next.js middleware) - The capabilities this user has in what Ably channels, dependent on their role
- 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.
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fably-token-flow.png&w=3840&q=75)
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:
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:
npm install jose
Once jose
installs, create 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:
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:
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
:
'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
:
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!
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fauthenticated-chat.png&w=3840&q=75)
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.
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Ferr.png&w=3840&q=75)
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:
npm install lucide-react
This is where we’ll get the lock icon from.
Next, update 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:
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fmods-only.png&w=3840&q=75)
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:
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fclerk-user-list.png&w=3840&q=75)
Scroll to the bottom and Edit the public metadata to look like this:
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fclerk-public-metadata.png&w=3840&q=75)
Hit Save, then reload your app for good measure.
Your user and fellow moderators will now have access to the channel:
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fmods-only-unlocked.png&w=3840&q=75)
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):
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:
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:
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.
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fenable-message-interaction.png&w=3840&q=75)
Once you’ve done that, update 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
:
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.
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fcan-delete-messages.png&w=3840&q=75)
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:
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fcant-delete-messages.png&w=3840&q=75)
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/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fenable-message-hist.png&w=3840&q=75)
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:
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.
![](/_next/image?url=%2F_next%2Fstatic%2Fmedia%2F_blog%2Fauthenticated-next-chat-app-with-ably-and-clerk%2Fu-up.png&w=3840&q=75)
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
:
'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
:
'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!