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.
This 6000-word mega-post teaches you how to code an authenticated chat application with roles and permissions from scratch!
You will learn how to implement message transmission over WebSockets and a dynamic "who's online?" list that updates in realtime.
We'll also implement a secure moderator role that enables select users to access restricted channels like "#moderators-only" and delete unwanted messages.
As for the technology stack, it's React on the frontend using Tailwind and shadcn/ui for styling. We'll integrate Ably for serverless WebSockets and Clerk for authentication and user management.
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
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
- A 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
- An awareness of Flexbox and Grid
Start here
First, let's create a blank Next.js App Router project called chat-tutorial
.
The easiest way is with create-next-app
:
Run the above command then cd
into the newly-created directory:
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
:
Next, create src/app/chat/[[...channelName]]/page.js
:
Run the development server with npm run dev
, open your browser, then navigate to /chat
to observe the three-column skeleton:
In the upcoming 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 segment by params.channelName
and use the value do determine what channel the user is currently 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 before, 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:
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 the MessageInput
and MessageList
components.
To make them look sleek without writing much code, let's lean on the shadcn/ui's ready-made Input
and Avatar
components.
To download them, run the following commands:
Create src/app/[[...channel]]/chat/message-input.js
:
Finally, create src/app/[[...channel]]/chat/message-list.js
:
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 allows us to benefit from realtime communication, even when deploying to serverless platforms such as Vercel that don't typically support long-lived WebSocket connections.
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 can 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. This grouping will come in handy later when we apply Ably access 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 momentarily:
Back in the terminal, install the Ably React SDK:
Create src/app/[[...channel]]/chat/chat.js
:
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:
With that, the basic chat is ready to test!
Open /chat/general in a couple of localhost browser 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 the excerpt, we instantiate an Ably Realtime
instance with the API key. We also hard-coded the clientId
.
This is problematic:
- An Ably API key is top secret and should never (ever) go in production client-side code as a malicious actor could easily find it and exploit your application. I have done so here only for demonstration purposes.
- 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:
Next, create src/app/[[...channel]]/chat/channel-list.js
:
Finally, update src/app/[[...channel]]/chat/page.js
:
Using clsx
, we conditionally bolden the current channel to convey the channel they're currently chatting in.
Here, I hardcoded a channels
list but this same data structure could come from a database should you want to enable users to create channels dynamically.
Return to the browser and check it out - the user can now switch channels without fumbling with the URL in the address bar:
Authentication with Clerk
With basic realtime messaging in place, it's time to authenticate Next.js users with Clerk.
Clerk is an authentication and user management platform with readyade React components and seamless Next.js authentication middleware.
With Clerk, you get an assortment of beautifully-designed React components. Some are UI components like SignInButton
and UserButton
, while others are "helper components" that conditionally render children based on some authentication or authorization state.
It's typical to use both kinds of components, and they make adding fully-featured authentication to Next.js a lot smoother than you might be expecting if you have experience rolling your own auth.
User management
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 of "Alex" we have been relying on until now.
The Clerk dashboard UI provides a convenient user management backoffice to manage 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 the styling options you choose. I set the background to Solid and chose the color #BDBDBD but you can make this your own!
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:
Implement authentication with Clerk
To start authenticating Next.js users with Clerk, we'll first need to install the Clerk Next SDK:
Next, create src/middleware.js
:
This middleware makes it so that only authenticated users can access routes starting with /chat
.
If the user isn't authenticated, they'll be redirected to the Clerk account portal to sign-up or sign-in.
Next, update src/app/layout.js
:
Here, we utilize some of those Clerk React components I mentioned before:
<ClerkProvider />
wraps your React app to provide session and user context.<SignInButton />
links to the sign-in page or shows the sign-in modal.<UserButton />
renders the user's avatar with a dropdown for account management.<SignedIn />
renders children only if a user is signed in.<SignedOut />
renders children only if no user is signed in.<ClerkLoaded />
ensures the Clerk object is loaded and accessible.
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 their ability to connect to restricted channels.
Understanding tokens
As a reminder, earlier, we hard-coded the Ably API key in the client-side code:
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 which Ably channels, dependent on their role.
- Additional claims, such as
isMod
.
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 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 publish. Capabilities 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 the key over from src/app/[[...channel]]/chat/page.js
or fetch it from the Ably dashboard again:
Going forward, we'll only reference the environment variable (we'll remove it from the client-side code in due course).
Next, install jose
, a JWT helper library:
Once jose
installs, create src/app/api/ably/route.js
:
This is the backend authentication endpoint. It utilizes Clerk's currentUser
function to access information about the currently-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 give them unrestricted Ably capabilities with *
. Otherwise, they are limited to 3 channels:
What's more, we encode the claim
in the token:
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 (yours) 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
:
Finally, update src/app/[[...channel]]/chat/chat.js
:
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 confusing 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:
This is where we'll get the lock icon from.
Next, update src/app/[[...channel]]/chat/channel-list.js
:
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):
Subscribers receive this event, at which point, we will execute some logic to process the message deletion:
- 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:
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
:
Finally, update src/app/[[...channel]]/chat/chat.js
:
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:
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
:
Now, for the final time in this tutorial, update src/app/[[...channel]]/chat/page.js
:
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!
Ready to get started?
Sign up today