Build a Realtime Chat App with Directus and Nuxt
Directus offers realtime capabilities, powered by websockets. You can use these with the Directus SDK to create your own realtime applications. In this tutorial, you will build a chat application using Nuxt and a Directus project. a
Before You Start
You will need:
- A Directus project with admin access.
- Fundamental understanding of Nuxt concepts.
- Optional but recommended: Familiarity with data modeling in Directus.
Set Up Your Directus Project
Create a Collection
Create a new collection called messages
with the following fields:
content
(Type: textarea)
After which you can go to the optional fields and activate the following:
user_created
date_created
Edit Public Policy
So that Nuxt can access the messages collection you need to edit the public policy. Navigate to Settings -> Access Policies -> Public
and under Permissions add messages
with full access for create
and read
.
The frontend will display the name of the user who created the message so the public policy will also need to have access to the directus_users
collection. Add directus_users
with custom read
access and under Field Permissions check first_name
and last_name
.
Create a User for Chatting
Messages will need to be assigned to a user. Create a new user in Directus by navigating to User Directory -> Add User and create a new user. Be sure to remember the email and password you use. Assign the user with the Public policy that was edited in the previous step by clicking "Add Existing" under policies and selecting "Public".
Configure Realtime
Directus Realtime may disabled on self-hosted projects. To enable it if you are using Docker, edit your docker-compose.yml
file as follows:
environment:
WEBSOCKETS_ENABLED: "true"
WEBSOCKETS_HEARTBEAT_ENABLED: "true"
If you use Directus Cloud to host your project, you do not need to manually enable Realtime.
Set Up Your Nuxt Project
Initialize Your Project
Create a new Nuxt project using Nuxi:
npx nuxi@latest init directus-realtime
cd directus-realtime
Note: Just hit enter when asked to select additional packages (none are required for this project).
Configure Nuxt
Configure Nuxt so that it is able to communicate with the (external) Directus API.
Create a .env
file with the Directus URL:
API_URL="http://0.0.0.0:8055"
Add a type definition for our new environment variable by creating an env.d.ts
file with the following content:
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
Depending on your project configuration and if you are in development or production you may need to configure a Nuxt proxy to allow access between your Nuxt project and Directus in your nuxt.config.ts
:
routeRules: {
"/directus/**": { proxy: `${import.meta.env.API_URL}/**` },
},
This will allow your Nuxt project to access directus via your Nuxt URL, eg. http://localhost:3000/directus
Inside your Nuxt project, install the Directus SDK package by running:
npm install @directus/sdk
Define a Directus Schema
TypeScript needs to know what the structure of the Directus data is. To achieve this create a directus.d.ts
file in the root of our project which defines our schema:
/// <reference types="@directus/extensions/api.d.ts" />
interface DirectusSchema {
messages: Message[];
}
interface Message {
id: number;
content: string;
user_created: string;
date_created: string;
}
Use Nuxt page router
Configure Nuxt to use the page router by editing app.vue
replacing the content with:
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtPage />
</div>
</template>
Create a Directus plugin
Create a Nuxt plugin to streamline accessing Directus throughout your application. Create a new file plugins/directus.ts
Copy and paste in the code below, replace the your-website-url
with your Nuxt URL and port:
import { createDirectus, realtime } from "@directus/sdk";
const directus = createDirectus<DirectusSchema>(
"http://your-website-url/directus",
).with(realtime());
export default defineNuxtPlugin(() => {
return {
provide: { directus },
};
});
This file handles all the interaction with Directus and provides Nuxt with the required Directus SDK features.
Create a Login Form
The chat system will need to know who is sending messages to Directus so the user will need to login before they can send messages. The websocket will return a refresh token Nuxt can use this to determine if a user is logged in. In pages/index.vue
script set up add some variables to store the token and the login credentials.
<script setup lang="ts">
const { $directus } = useNuxtApp()
const refreshToken: Ref<string | undefined> = ref()
const credentials = ref({
email: '',
password: ''
})
</script>
Then, in the template, add a form to capture the user's email and password and display it if there is no token.
<template>
<div>
<h1>Directus Realtime Chat</h1>
<div v-if="refreshToken === undefined">
<h2>Login</h2>
<input v-model="credentials.email" type="text" placeholder="Email" /><br />
<input v-model="credentials.password" type="password" placeholder="Password" /><br />
<button @click="login" type="button">Login</button>
</div>
<div v-else>
<h2>Chat</h2>
<div>Logged in</div>
</div>
</div>
</template>
If you run npm run dev
and navigate to http://localhost:3000
you should see a login form.
Directus Realtime (Websockets) will be used to authenticate the user as well as send and receive messages. To connect the client to Directus use handshake mode which requires a connection followed quickly and immediately by an authentication.
After the variable definitions in pages/index.vue
script setup add the following code:
const saveRefreshToken = (token: string) => {
refreshToken.value = token
localStorage.setItem('directus_refresh_token', token)
}
onMounted(() => {
const storedToken = localStorage.getItem('directus_refresh_token')
if (storedToken) {
refreshToken.value = storedToken
$directus.connect()
$directus.onWebSocket('open', () => {
$directus.sendMessage({
type: 'auth',
refresh_token: storedToken
})
})
} else {
$directus.connect()
}
const cleanup = $directus.onWebSocket('message', (message) => {
if (message.type === 'auth' && message.status === 'ok') {
saveRefreshToken(message.refresh_token)
}
})
onBeforeUnmount(cleanup)
})
const login = async () => {
const login = {
type: 'auth',
email: credentials.value.email,
password: credentials.value.password
}
$directus.sendMessage(JSON.stringify(login))
}
The code added above does the following:
- Check if there is an existing refresh token in local storage. If there is, connect to Directus and authenticate using the refresh token. If not, just connect to Directus.
- Set up a listener for the
message
event on the websocket. When any message is received, check if it is an authentication message and if it is, save the refresh token to local storage. - Provide a login function that sends the credentials from the login form to Directus for authentication.
Visit http://your-website-url
and try logging in with the user you created in Directus in the steps above.
Subscribe to Incoming Messages
Although $directus.onWebSocket('message', (message) => {}
will receive all messages, the Directus SDK provides a more convenient way to subscribe to specific events. In this case the client can subscribe to the messages
collection to receive specific fields from any messages as they are created and uniquely identify our subscription with a UID for best practice.
At the bottom of the setup script in pages/index.vue
add the following code:
const messageList: Ref<Message[]> = ref([])
const subscribe = async (event) => {
const { subscription } = await $directus.subscribe('messages', {
event,
query: {
fields: ['*', 'user_created.first_name'],
},
uid: "messages-subscription"
})
for await (const message of subscription) {
receiveMessage(message)
}
}
const receiveMessage = (data) => {
if (data.type === 'ping') {
$directus.sendMessage({
type: 'pong',
})
}
if (data.type === 'subscription' && data.event === 'create') {
const message = data.data[0]
addMessageToList(message)
}
}
const addMessageToList = (message: Message) => {
messageList.value.push(message)
}
This subscribes to the messages
collection when the user is authenticated. Update the cleanup function to include the subscription:
const cleanup = $directus.onWebSocket('message', (message) => {
if (message.type === 'auth' && message.status === 'ok') {
saveRefreshToken(message.refresh_token)
subscribe('create')
}
})
Then display the message list in the template by updating the else
condition:
<div v-else>
<h2>Chat</h2>
<div v-for="message in messageList" :key="message.id">
{{ message.user_created.first_name }}: {{ message.content }}
</div>
</div>
Visit http://your-website-url
and you should see an empty chat window after logging in. Be sure to refresh the page rather than relying on hot reload which may cause connections issues with websockets. Go back to Directus (hint: this is best done with 2 browser windows side by side) and create a new message in the messages
collection. You should see the message appear in the chat window.
Send Messages
Having proven that Nuxt can receive messages created in Directus, add a new form to our template to send messages from Nuxt. In the template section of pages/index.vue
replace the existing else
statement with the following:
<div v-else>
<h2>Chat</h2>
<div v-for="message in messageList" :key="message.id">
{{ message.user_created.first_name }}: {{ message.content }}
</div>
<form @submit.prevent="messageSubmit">
<label for="message">Message</label>
<input v-model="newMessage" type="text" id="text" />
<input type="submit" />
</form>
<button type="button" @click="logout">Logout</button>
</div>
Now add code to the script setup section of pages/index.vue
to make the form work. Directly under the last function, add the following:
const newMessage: Ref<string> = ref('')
const messageSubmit = () => {
$directus.sendMessage({
type: 'items',
collection: 'messages',
action: 'create',
data: { content: newMessage.value },
})
newMessage.value = ''
}
const logout = () => {
$directus.sendMessage({
type: 'auth',
action: 'logout',
})
refreshToken.value = undefined
localStorage.removeItem('directus_refresh_token')
}
Visit your website url again (remember to refresh) and enter a message in the form and submit it. The message should appear in the chat window, with the first name of the user. You can also logout of the chat by clicking the logout button but if you do this you will notice the previously added messages have disappeared.
Fetching the Latest Messages On Load
When the page first loads there are no messages in the chat window. This can be fixed by making a request for the latest messages from Directus using a realtime message when the page first loads. Add another function to the script setup section of pages/index.vue
:
const readAllMessages = () => {
$directus.sendMessage({
type: 'items',
collection: 'messages',
action: 'read',
query: {
limit: 10,
sort: '-date_created',
fields: ['*', 'user_created.first_name'],
},
uid: 'get-recent-messages'
})
}
To call this function when the page loads, replace the cleanup
function with the following:
const cleanup = $directus.onWebSocket('message', (message) => {
if (message.type === 'auth' && message.status === 'ok') {
saveRefreshToken(message.refresh_token)
if (messageList.value.length === 0) {
readAllMessages()
subscribe('create')
}
}
// The only message of type items required to process is the initial array of messages
// All other messages are handled by the subscription
if (message.uid === 'get-recent-messages' && message.type === 'items') {
for (const item of message.data) {
messageList.value.unshift(item)
}
}
})
When the message list is returned Nuxt can identify it by the uid
that was set in the readAllMessages
function. Messages are then added to the message list in reverse order so that the most recent messages are at the bottom.
Visit your website url again and refresh the page. You should see the last 10 messages in the chat window.
Handling Connection Stability
Directus Realtime uses websockets to maintain a connection to the server. Behind the scenes Directus is sending a heartbeat or ping message every 30 seconds to keep the connection alive. If the connection is lost, then the user will not receive updates. Nuxt already responds to this message in receiveMessage
by sending a pong message back to Directus.
To ensure a stable connection use the refresh token from handshake mode to re-authenticate the user and re-subscribe to the messages collection.
At the bottom of the script setup section in pages/index.vue
add the following code:
$directus.onWebSocket('close', () => {
if (refreshToken.value) {
$directus.connect()
$directus.sendMessage({
type: 'auth',
refresh_token: refreshToken.value
})
}
})
Now if the connection is lost, Nuxt will attempt to reconnect and re-authenticate the user.
Summary
Realtime communication via websockets is a powerful feature of Directus that can be used, not just for message communication but also user authentication and data filtering and synchronization.
The full code from this tutorial can be found on Github.