TL;DR
In this article, we will look at how to build a simple message board web app with Next.js and AppWrite. This is a good context for learning how to better use these tools and to build something useful along the way.
A small request
I'm trying to reach 1K GitHub stars for "Preevy" - an open source tool my team and I just launched for easily creating shareable preview environments.
Can you help us out by starring the GitHub repository? It would help us a lot! Thank you! https://github.com/livecycle/preevy
And now, if you're ready, let's dive in.
Introduction
In this article, we will look at how to build a simple message board web app with Next.js and AppWrite. In the end, we’ll have a working web app where users can authenticate, post their messages and read the messages of others. I worked with developers on my team to put this guide together. We think this is a good context for learning how to better use these tools and to build something useful along the way.
Next.js, a popular React framework, offers exceptional performance and server-side rendering capabilities, making it a go-to choice for creating interactive web apps. AppWrite, on the other hand, makes it super simple to write backend code without having to set up your own backend. You can call it directly from your Next.js front-end. AppWrite can handle all tasks of a traditional backend for you: databases, authentication, file uploads, and much more.
For styling, we will use TailwindCSS. And to handle calling AppWrite from the front-end, we will use the data fetching and caching library React Query.
Setup
System prerequisites
- Have a current version of Node.js along with NPM installed
- Have Docker Desktop installed and running
AppWrite installation
During development, we host an instance of AppWrite on our local machine. This can be done with Docker. You should already have this set up on your machine. Internally, AppWrite consists of various services. Such as Redis and MariaDB. Setting up all these services manually would be a hassle. Luckily, AppWrite comes with a docker-compose
configuration, which can start all services in one command.
To get started, create a new foldermessage-board/
and in there run this command to provision and start the AppWrite service. Downloading the Docker images might take a while. During the setup process, chose the default options everywhere and enter a random secret key.
docker run -it --rm \
--volume /var/run/docker.sock:/var/run/docker.sock \
--volume "$(pwd)"/appwrite:/usr/src/code/appwrite:rw \
--entrypoint="install" \
appwrite/appwrite:1.3.4
Afterward, go to http://localhost and create a new admin account to manage AppWrite. With this, we can now create and configure our AppWrite project. First off, sign up with a new account. Then, click “Create Project” and enter message-board
as the name.
Now select “Add a Platform” → “Web App”. Name it message-board
again and use *
for the hostname since this is just for local development and we want to make sure we can access AppWrite from the Next.js app.
You can skip the optional steps regarding the setup in JavaScript. We’ll do that later. Once you’re in the dashboard, go to “Databases” and create a new Database. Again, name it message-board
. Leave the “Database ID” empty so that it gets randomly generated.
Now create a collection inside the newly created database. For this, click “Create collection” and call it messages
. Once again, leave the ID field empty.
Inside the messages
schema, create a new “String” attribute with a size of 1024
and mark it as “Required”. This is the attribute where we’ll later store the messages users submit.
While already here, we should also set up the permissions for accessing this collection. Go to the collection “Settings” tab and configure the permissions according to this. We’ll adjust these again once we get to the authentication section of the tutorial.
Next.js Setup
Now back to the terminal. In the previously created folder message-board/
run this command to provision a new Next app. Observe the settings we picked below. You must say Yes to Tailwind CSS and No to using the App Router.
Now change into the message-board/message-board-app/
folder and install the dependencies we need for AppWrite and ReactQuery:
npm install appwrite react-query
Let’s open the Next.js app in your editor of choice. In the src/pages/_app.js
file, configure React Query. We’ll use that later to fetch data from AppWrite.
import { QueryClient, QueryClientProvider } from 'react-query';
import '@/styles/globals.css'
export default function App({ Component, pageProps }) {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
);
}
Now, inside your Next.js folder, create a new file called .env.local
. In there, we need to store our AppWrite access information:
NEXT_PUBLIC_ENDPOINT=http://localhost/v1
NEXT_PUBLIC_PROJECT=646XXX
NEXT_PUBLIC_DATABASE=646XXX
NEXT_PUBLIC_MESSAGES_COLLECTION=646XXX
The endpoint will always be the same, provided you’ve followed the docker-compose installation. The other three IDs need to be copied from the AppWrite dashboard. You can find them right next to the heading of the Project, Database, and Collection name. Be careful to not mix up the project, database and collection IDs, as they all look very similar.
Building the message board
Basic scaffolding
On the src/pages/index.js
page first remove the existing boilerplate and then create the scaffolding for displaying, creating, and deleting messages. For now, we will display static data and add the interactive functionality later.
import { useState } from 'react';
const messages = [
{ $id: 1, message: 'Hello world' },
{ $id: 2, message: 'Hello world 2' },
];
export default function Home() {
const [input, setInput] = useState('');
return (
<main className="flex min-h-screen justify-center p-24 text-black">
<div className="bg-white rounded-lg shadow-lg p-8">
<h1 className="text-4xl py-8 font-bold text-center">Message board</h1>
<ul className="space-y-4">
{messages?.map((message) => (
<li key={message.$id} className="flex items-center justify-between w-full space-x-4">
<p className="text-gray-700">{message.message}</p>
<button className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md">Delete</button>
</li>
))}
</ul>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-full border border-gray-300 rounded-md p-2 mt-4"
/>
<button className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md mt-4">Submit message</button>
</div>
</main>
);
}
By the way, if you want to see how the application looks, start it with by typing npm run dev
in your terminal in the message-board-app/
directory.
Adding new messages
First, create a new file at src/appwrite.js
which will contain all our communication with the AppWrite backend. The addMessage
is an asynchronous function. It takes the message string as an input. Then it will make a call to our AppWrite instance where the message will be saved to the database.
import { Account, Client, Databases, ID } from 'appwrite';
const client = new Client();
const account = new Account(client);
const database = process.env.NEXT_PUBLIC_DATABASE;
const collection = process.env.NEXT_PUBLIC_MESSAGES_COLLECTION;
client.setEndpoint(process.env.NEXT_PUBLIC_ENDPOINT).setProject(process.env.NEXT_PUBLIC_PROJECT);
const databases = new Databases(client);
export const addMessage = async (message) => {
await databases.createDocument(
database,
collection,
ID.unique(),
{
message,
}
);
};
Then modify the src/pages/index.js
component to hook up the form for adding messages:
import { useState } from 'react';
import { useMutation, useQueryClient } from 'react-query';
import { addMessage } from '@/appwrite';
const messages = [
{ $id: 1, message: 'Hello world' },
{ $id: 2, message: 'Hello world 2' },
];
export default function Home() {
const [input, setInput] = useState('');
const queryClient = useQueryClient();
const addMessageMutation = useMutation(addMessage, {
onSuccess: () => {
setInput('');
queryClient.invalidateQueries('messages');
},
});
return (
<main className="flex min-h-screen justify-center p-24 text-black">
<div className="bg-white rounded-lg shadow-lg p-8">
<h1 className="text-4xl py-8 font-bold text-center">Message board</h1>
<ul className="space-y-4">
{messages?.map((message) => (
<li key={message.$id} className="flex items-center justify-between w-full space-x-4">
<p className="text-gray-700">{message.message}</p>
<button className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md">Delete</button>
</li>
))}
</ul>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-full border border-gray-300 rounded-md p-2 mt-4"
/>
<button
onClick={() => addMessageMutation.mutate(input)}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md mt-4"
>
Submit message
</button>
</div>
</main>
);
}
Now we can add messages to the database. However, we can’t see them yet as we are not fetching any messages from AppWrite. We will fix that shortly.
But what exactly are we doing here? We have created a React Query mutation with the useMutation
hook. It allows us to then call addMessageMutation.mutate(input)
on the “Submit message” button. And even more importantly, in the hook, we added an onSucess
callback. So whenever adding a new message is successful, we can clear the input field and invalidate the query cache. Invalidating the cache will prompt React Query to fetch all messages again. This will become useful in the next section where we fetch messages from AppWrite.
Rendering messages
Now that we can add messages to the database, we also want to display them. First we’ll need to add the fetch messages action to the src/appwrite.js
file:
// other code
export const getMessages = async () => {
const { documents: messages } = await databases.listDocuments(database, collection);
return messages;
};
Now we can fetch them with the React Query useQuery
hook:
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from 'react-query';
import { addMessage, getMessages } from '@/appwrite';
export default function Home() {
const [input, setInput] = useState('');
const queryClient = useQueryClient();
const { data: messages } = useQuery('messages', getMessages);
const addMessageMutation = useMutation(addMessage, {
onSuccess: () => {
setInput('');
queryClient.invalidateQueries('messages');
},
});
return (
<main className="flex min-h-screen justify-center p-24 text-black">
<div className="bg-white rounded-lg shadow-lg p-8">
<h1 className="text-4xl py-8 font-bold text-center">Message board</h1>
<ul className="space-y-4">
{messages?.map((message) => (
<li key={message.$id} className="flex items-center justify-between w-full space-x-4">
<p className="text-gray-700">{message.message}</p>
<button
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md"
>
Delete
</button>
</li>
))}
</ul>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-full border border-gray-300 rounded-md p-2 mt-4"
/>
<button
onClick={() => addMessageMutation.mutate(input)}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md mt-4"
>
Submit message
</button>
</div>
</main>
);
}
Add some messages via the form so you can see them! Notice that whenever you submit a new message, the existing messages get automatically reloaded - so that the new message appears in the list. That’s the magic of React Query.
Deleting messages
The last missing step is the ability to delete messages. Once again, we first need to add this function to src/appwrite.js
:
// other code
export const deleteMessage = async (id) => {
await databases.deleteDocument(database, collection, id);
};
And then, all that’s left is adding one more mutation and the onClick
action on the delete button to the src/pages/index.js
page. Don’t copy the whole component. Only copy the added delete mutation and the new return statement. Also, make sure to import deleteMessage
from AppWrite.
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from 'react-query';
import { addMessage, deleteMessage, getMessages } from '@/appwrite';
export default function Home() {
// other code
const deleteMessageMutation = useMutation(deleteMessage, {
onSuccess: () => {
queryClient.invalidateQueries('messages');
},
});
return (
<main className="flex min-h-screen justify-center p-24 text-black">
<div className="bg-white rounded-lg shadow-lg p-8">
<h1 className="text-4xl py-8 font-bold text-center">Message board</h1>
<ul className="space-y-4">
{messages?.map((message) => (
<li key={message.$id} className="flex items-center justify-between w-full space-x-4">
<p className="text-gray-700">{message.message}</p>
<button
onClick={() => deleteMessageMutation.mutate(message.$id)}
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md"
>
Delete
</button>
</li>
))}
</ul>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-full border border-gray-300 rounded-md p-2 mt-4"
/>
<button
onClick={() => addMessageMutation.mutate(input)}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md mt-4"
>
Submit message
</button>
</div>
</main>
);
}
Once again, after successfully deleting a message we tell React Query to refetch all messages so that the deleted message disappears from the list.
Authentication
To add authentication, we first need some additional actions in the src/appwrite.js
file. This is so that users can sign up and afterward sign in. Also, we need a sign out action and a function to fetch the user session. To store the user session, also create a new React Context in this file. Make sure to import createContext
from react
.
// other code
export const signUp = async (email, password) => {
return await account.create(ID.unique(), email, password);
};
export const signIn = async (email, password) => {
return await account.createEmailSession(email, password);
};
export const signOut = async () => {
return await account.deleteSessions();
};
export const getUser = async () => {
try {
return await account.get();
} catch {
return undefined;
}
};
// import createContext from react beforehand
export const UserContext = createContext(null);
We now need to fetch the user automatically on every application start and make it available through our context. We’ll do this in src/pages/_app.js
:
import { useEffect, useState } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { UserContext, getUser } from '@/appwrite';
import '@/styles/globals.css';
export default function App({ Component, pageProps }) {
const [user, setUser] = useState(null);
const queryClient = new QueryClient();
useEffect(() => {
const user = async () => {
const user = await getUser();
if (!user) return;
setUser(user);
};
user();
}, []);
return (
<UserContext.Provider value={{ user, setUser }}>
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
</UserContext.Provider>
);
}
Then we can build the masks for sign up. For this add a new file src/pages/signup.js
with the following content:
import { useRouter } from 'next/router';
import { useState } from 'react';
import { signUp } from '@/appwrite';
export default function SignUp() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const router = useRouter();
const handleSubmit = async (e) => {
e.preventDefault();
try {
await signUp(email, password);
router.push('/');
} catch {
console.log('Error signing up');
}
};
return (
<div className="flex items-center justify-center h-screen">
<div className="max-w-sm mx-auto">
<form onSubmit={handleSubmit} className="bg-white shadow-md rounded px-8 py-6 mb-4">
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="email">
Email
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="email"
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
Password
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{/* Submit button */}
<div className="flex items-center justify-between">
<button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
type="submit"
>
Sign Up
</button>
</div>
</form>
</div>
</div>
);
}
And sign in at src/pages/signin.js
:
import { useRouter } from 'next/router';
import { useState } from 'react';
import { signIn } from '@/appwrite';
export default function SignIn() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const router = useRouter();
const handleSubmit = async (e) => {
e.preventDefault();
try {
await signIn(email, password);
router.push('/');
} catch {
console.log('Error signing in');
}
};
return (
<div className="flex items-center justify-center h-screen">
<div className="max-w-sm mx-auto">
<form onSubmit={handleSubmit} className="bg-white shadow-md rounded px-8 py-6 mb-4">
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="email">
Email
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="email"
type="email"
placeholder="Enter your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
Password
</label>
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="flex items-center justify-between">
<button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
type="submit"
>
Sign In
</button>
</div>
</form>
</div>
</div>
);
}
Signing out
Now, for the last piece of the user authentication, we need to hook up the sign out functionality. We already have all the pieces for this. We just need to check if the user
session exists and if yes, display a “Sign Out” button that calls the signOut
function from AppWrite. We’ll change the src/pages/index.js
file to this:
import { useContext, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from 'react-query';
import { addMessage, getMessages, deleteMessage, UserContext, signOut } from '@/appwrite';
export default function Home() {
const [input, setInput] = useState('');
const queryClient = useQueryClient();
const { user, setUser } = useContext(UserContext);
const { data: messages } = useQuery('messages', getMessages);
const addMessageMutation = useMutation(addMessage, {
onSuccess: () => {
setInput('');
queryClient.invalidateQueries('messages');
},
});
const deleteMessageMutation = useMutation(deleteMessage, {
onSuccess: () => {
queryClient.invalidateQueries('messages');
},
});
const handleSignOut = async () => {
await signOut();
setUser(null);
};
return (
<main className="flex min-h-screen justify-center p-24 text-black">
<div className="bg-white rounded-lg shadow-lg p-8">
<h1 className="text-4xl py-8 font-bold text-center">Message board</h1>
<ul className="space-y-4">
{messages?.map((message) => (
<li key={message.$id} className="flex items-center justify-between w-full space-x-4">
<p className="text-gray-700">{message.message}</p>
<button
onClick={() => deleteMessageMutation.mutate(message.$id)}
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md"
>
Delete
</button>
</li>
))}
</ul>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-full border border-gray-300 rounded-md p-2 mt-4"
/>
<button
onClick={() => addMessageMutation.mutate(input)}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md mt-4"
>
Submit message
</button>
{user && (
<button
onClick={handleSignOut}
className="bg-red-500 ml-4 hover:bg-red-600 text-white px-4 py-2 rounded-md mt-4"
>
Sign out
</button>
)}
</div>
</main>
);
}
Access control
With authentication implemented, we now want to ensure that only authenticated users can create new messages. And that users can only delete their own messages. So let’s go back to the dashboard and change the messages
schema permissions to this:
As you can see, unauthenticated users can only read messages from now on. If you want to create messages, you need to be authenticated. But how can we enable users to delete their own messages? We’ll do this on a per-document basis. Think of a document as one row in the database. So for that, on the same screen, also make sure you turn on “Document Security”.
Back in the code, modify the addMessage
function in src/appwrite.js
as follows. Also, don’t forget to update the import statement as shown.
import { Account, Client, Databases, Permission, Role, ID } from 'appwrite';
export const addMessage = async ({ message, userId }) => {
await databases.createDocument(
database,
collection,
ID.unique(),
{
message,
},
[Permission.delete(Role.user(userId))]
);
};
That way, we explicitly state that only the owner of the document can delete it. Now, in src/app/index.js
in the add message button action, also pass the user.$id
of our current user, so AppWrite knows which user we want to give delete permission to:
<button
onClick={() => addMessageMutation.mutate({ message: input, userId: user?.$id })}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md mt-4"
>
Submit message
</button>
Now, create a new user on /signup
and then use the same credentials on/signin
to sign in. Afterward, you will be able to create new messages linked to your account. Try deleting those messages and see what happens.
Showing the auth state in the UI
Great! Now only owners of messages can delete them. But we’re still showing a delete button beside every message. No matter which user we’re logged in with. Same for the submit a new message field which is visible for unauthenticated users. So we need to change our UI only to show these options when they can actually be performed by the user. For the submit a new message form that’s simple. We’ll just show it whenever a user is logged in:
{user && (
<>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
className="w-full border border-gray-300 rounded-md p-2 mt-4"
/>
<button
onClick={() => addMessageMutation.mutate({ message: input, userId: user?.$id })}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md mt-4"
>
Submit message
</button>
<button
onClick={handleSignOut}
className="bg-red-500 ml-4 hover:bg-red-600 text-white px-4 py-2 rounded-md mt-4"
>
Sign out
</button>
</>
)}
It’s a bit more complicated for checking if a user owns the message. Every message comes with a permissions array. If the user owns that message, their user.$id
will be present in the delete
permission field. We’ll write a function to check for that:
const canDelete = (userID, array) => {
return array.some((element) => element.includes('delete') && element.includes(userID));
};
And then check for that in the UI for each message to see if we need to render the delete button:
{messages?.map((message) => (
<li key={message.$id} className="flex items-center justify-between w-full space-x-4">
<p className="text-gray-700">{message.message}</p>
{canDelete(user?.$id, message.$permissions) && (
<button
onClick={() => deleteMessageMutation.mutate(message.$id, user.$id)}
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md"
>
Delete
</button>
)}
</li>
))}
Wrapping up
And with this, we’re done! Users will now only see the actions they actually have permission for. As this tutorial showed, Next.js, AppWrite, and React Query played very nicely together. Thanks to AppWrite in combination with React Query we get an amazing full-stack development experience without writing any backend code.
We hope you learned something new while following this tutorial. The application we built can serve as a starting point for your own project. If you want to continue building this message board, here are a few feature ideas:
- Add comments to messages. This will teach you how to do relations in AppWrite.
- Add infinite scrolling or pagination to deal with a large number of messages that cannot be rendered all at once.
- Use Next’s Server Side Rendering to fetch and render messages on the server instead of the client.
Regarding deployment, we already set up the AppWrite in a neatly packaged docker-compose setup. You could host this on popular Cloud Providers such as Google Cloud or AWS. As a bonus, add Next.js into the Docker environment using this guide by Vercel.
And while you’re at it, you can also set up preview environments for your project using Preevy. This will easily provision preview environments for your application that you can share with others to get quick feedback and keep your development workflow moving at a good clip.
If you don’t want to go down the self-hosting route, AppWrite also offers a cloud service where that host it for you. But then you’ll need to host the Next.js separately on Vercel or Netlify.
Hope you enjoyed and found this guide helpful.
Good luck!