Summarize with AI:
Build a basic to-do app using Nuxt 4 and Appwrite for hands-on setup and TablesDB experience.

Nuxt has gone through a few updates since Nuxt 4, and Appwrite has some new ways of handling data with TableDB. Let’s build a TODO app!
⚠️ This app assumes you have basic Nuxt knowledge and can create an Appwrite database.
We are building Nuxt Notes, a basic to-do app using Nuxt 4 and Appwrite. I could not find any good documentation on setting up Appwrite correctly with Nuxt 4, nor on handling the new TablesDB class. This will cover both.
notes-app.notes. Copy the Database ID for later.
notes.
note string column and an author string column.
Settings and then Permissions.
Now we need to configure Nuxt 4 before we handle the notes.
npm i appwrite.First, update nuxt.config.ts to read from .env file.
import tailwindcss from "@tailwindcss/vite";
export default defineNuxtConfig({
compatibilityDate: "2025-07-15",
devtools: { enabled: true },
css: ['./app/assets/css/main.css'],
vite: {
plugins: [
tailwindcss(),
],
},
runtimeConfig: {
public: {
appwriteEndpoint: '',
appwriteProjectId: '',
appwriteDatabaseId: '',
},
},
});
NUXT_PUBLIC_ as the public prefix to allow it to be available on the client.We need to copy our environmental variables into .env from our Appwrite application.
NUXT_PUBLIC_APPWRITE_PROJECT_ID=69168c49003b2c79aafe
NUXT_PUBLIC_APPWRITE_DATABASE_ID=69b5c9ff0024d4feae10
NUXT_PUBLIC_APPWRITE_ENDPOINT=https://nyc.cloud.appwrite.io/v1
You can find this in project Settings.

<template>
<main class="flex items-center flex-col gap-5 p-5">
<h1 class="text-3xl font-bold text-red-800">Notes App!</h1>
<Auth />
</main>
</template>
Auth component will be created in a bit!We can create a simple loading spinner.
<template>
<div
class="animate-spin rounded-full h-10 w-10 border-4 border-b-current border-gray-200"
></div>
</template>
I believe it is bad practice to export data directly and import it in components. Nuxt has plugins for loading data the correct way.
import { ID, TablesDB } from 'appwrite'
import { Client, Account, Permission, Role, Query } from 'appwrite'
export default defineNuxtPlugin(() => {
const config = useRuntimeConfig()
const client = new Client()
const tables = new TablesDB(client)
client
.setEndpoint(config.public.appwriteEndpoint)
.setProject(config.public.appwriteProjectId)
const account = new Account(client)
const databaseId = config.public.appwriteDatabaseId
return {
provide: {
ID,
Permission,
Role,
Query,
client,
account,
tables,
databaseId
}
}
})
useRuntimeConfig()client, tables and account classes to be reusableclient.setEndpoint().setProject(), and export the databaseIdAppwrite does not have an observable like Firebase or Supabase. You must manually grab the user when you need it with account.get().
import type { Models } from "appwrite"
type User = Models.User<Models.Preferences>
export const useUser = () => {
const { $account } = useNuxtApp()
const user = useState<{
data: User | null,
loading: boolean
}>('user', () => ({
data: null,
loading: true
}))
const getUser = async () => {
try {
user.value.loading = true
user.value.data = await $account.get()
} catch (error) {
user.value.data = null
} finally {
user.value.loading = false
}
return user
}
return {
user,
getUser
}
}
user variable, and set it to be sharable from many components with useState instead of ref with the current user state.getUser will have to be called on component mounting.import { AppwriteException } from "appwrite"
export const useAuth = () => {
const { $account, $ID } = useNuxtApp()
const { getUser } = useUser()
const authError = ref<string | null>(null)
const authForm = ref({
email: "",
password: "",
name: ""
})
const login = async () => {
const { email, password } = authForm.value
try {
await $account.createEmailPasswordSession({
email,
password
})
await getUser()
} catch (error) {
if (error instanceof AppwriteException) {
authError.value = error.message
}
if (error instanceof Error) {
authError.value = error.message
}
throw error
}
}
const register = async () => {
const { email, password, name } = authForm.value
try {
await $account.create({
userId: $ID.unique(),
email,
password,
name
})
await login()
} catch (error) {
if (error instanceof AppwriteException) {
authError.value = error.message
}
if (error instanceof Error) {
authError.value = error.message
}
throw error
}
}
const logout = async () => {
try {
await $account.deleteSession({
sessionId: "current"
})
await getUser()
} catch (error) {
if (error instanceof AppwriteException) {
authError.value = error.message
}
if (error instanceof Error) {
authError.value = error.message
}
throw error
}
}
return {
register,
login,
logout,
authError,
authForm
}
}
login, register and logout. We can also create a shared variable for authError and authForm containing the email, password and name data.authError signal.await $account.create({
userId: $ID.unique(),
email,
password,
name
})
You must manually call login afterward.
await $account.createEmailPasswordSession({
email,
password
})
await getUser()
And we call getUser so that our user state signal gets updated reactively.
<script setup lang="ts">
const { user, getUser } = useUser()
onMounted(getUser)
</script>
<template>
<template v-if="user.loading">
<Loading />
</template>
<template v-else-if="user.data">
<Logout />
<Notes />
</template>
<template v-else>
<LoginForm />
</template>
</template>
getUser on mount to load the user state.<script setup lang="ts">
const { logout } = useAuth()
const { user } = useUser()
</script>
<template>
<h1>Hello {{ user.data?.name }}</h1>
<button
type="button"
@click="logout"
class="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-900 transition hover:bg-gray-300"
>
Logout
</button>
</template>
name using useUser, and create a link to logout using useAuth.Let’s handle registering and logging in!
<script setup lang="ts">
const { login, register, authError, authForm } = useAuth()
const isLogin = ref(true)
</script>
<template>
<div
class="mx-auto max-w-md rounded-xl border border-gray-200 bg-white p-6 shadow-sm"
>
<form class="space-y-3">
<input
autocomplete="email"
type="email"
placeholder="Email"
v-model="authForm.email"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none transition focus:border-gray-500"
/>
<input
autocomplete="current-password"
type="password"
placeholder="Password"
v-model="authForm.password"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none transition focus:border-gray-500"
/>
<template v-if="isLogin">
<p class="text-sm text-gray-600">
Don't have an account?
<button
type="button"
@click="isLogin = false"
class="text-blue-600 hover:underline"
>
Register
</button>
</p>
</template>
<template v-else>
<input
autocomplete="name"
type="text"
placeholder="Name"
v-model="authForm.name"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none transition focus:border-gray-500"
/>
<p class="text-sm text-gray-600">
Already have an account?
<button
type="button"
@click="isLogin = true"
class="text-blue-600 hover:underline"
>
Login
</button>
</p>
</template>
<template v-if="isLogin">
<button
type="button"
@click="login"
class="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-gray-800"
>
Login
</button>
</template>
<template v-else>
<button
type="button"
@click="register"
class="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-900 transition hover:bg-gray-300"
>
Register
</button>
</template>
<template v-if="authError">
<p class="mt-2 text-sm text-red-600">{{ authError }}</p>
</template>
</form>
</div>
</template>
isLogin will toggle login versus registering.useAuth composable!We need to add and remove notes.
import { AppwriteException } from "appwrite"
type Note = {
note: string,
id: string
}
export const useNotes = () => {
const notes = useState<{
data: Note[],
loading: boolean
}>('notes', () => ({
data: [],
loading: false
}))
const newNote = ref('')
const noteError = ref<string | null>(null)
const { user } = useUser()
const {
$tables,
$ID,
$databaseId,
$Permission,
$Role,
$Query
} = useNuxtApp()
const getNotes = async () => {
if (!user.value.data) {
throw new Error('User not authenticated')
}
const userId = user.value.data.$id
try {
notes.value.loading = true
const { rows } = await $tables.listRows({
databaseId: $databaseId,
tableId: 'notes',
queries: [
$Query.equal('author', userId)
]
})
notes.value.data = rows.map(row => ({
note: row.note,
id: row.$id
}))
} catch (error) {
if (error instanceof AppwriteException) {
noteError.value = error.message
}
if (error instanceof Error) {
noteError.value = error.message
}
throw error
} finally {
notes.value.loading = false
}
}
const addNote = async () => {
const note = newNote.value.trim()
const userData = user.value?.data
if (!userData) {
throw new Error('User not authenticated')
}
const userId = userData.$id
// optimistically add note
const rowId = $ID.unique()
notes.value.data.push({
note,
id: rowId
})
newNote.value = ''
try {
await $tables.createRow({
databaseId: $databaseId,
tableId: 'notes',
rowId,
data: {
note,
author: userId
},
permissions: [
$Permission.read($Role.user(userId)),
$Permission.update($Role.user(userId)),
$Permission.delete($Role.user(userId))
]
})
} catch (error) {
// remove optimistically added note
notes.value.data = notes.value.data.filter(n => n.note !== note)
newNote.value = note
if (error instanceof AppwriteException) {
noteError.value = error.message
}
if (error instanceof Error) {
noteError.value = error.message
}
throw error
}
}
const removeNote = async (id: string) => {
// optimistically remove note
const index = notes.value.data.findIndex(n => n.id === id)
if (index === -1) {
noteError.value = 'Note not found'
return
}
const note = notes.value.data[index]!
notes.value.data.splice(index, 1)
try {
await $tables.deleteRow({
databaseId: $databaseId,
tableId: 'notes',
rowId: id
})
} catch (error: unknown) {
// re-add optimistically removed note
notes.value.data.splice(index, 0, note)
if (error instanceof AppwriteException) {
noteError.value = error.message
}
if (error instanceof Error) {
noteError.value = error.message
}
throw error
}
}
const resetNotes = () => {
notes.value = {
data: [],
loading: false
}
}
return {
addNote,
removeNote,
notes,
noteError,
getNotes,
resetNotes,
newNote
}
}
getNotes to display them, addNotes from the form, and removeNotes from the button.const getNotes = async () => {
if (!user.value.data) {
throw new Error('User not authenticated')
}
...
notes signal, and if it fails, we remove them. This is what Firebase does automatically. We also optimistically remove them, and if it fails, add them back. This will create an extremely fast UI!notes.value.data.push({
note,
id: rowId
})
...
// optimistically remove note
const index = notes.value.data.findIndex(n => n.id === id)
if (index === -1) {
noteError.value = 'Note not found'
return
}
const note = notes.value.data[index]!
await $tables.createRow({
databaseId: $databaseId,
tableId: 'notes',
rowId,
data: {
note,
author: userId
},
permissions: [
$Permission.read($Role.user(userId)),
$Permission.update($Role.user(userId)),
$Permission.delete($Role.user(userId))
]
})
database id when we add a new note, and we want to match it with the user author it belongs to.Role.user(userId) gets created by the logged in userId.await $tables.deleteRow({
databaseId: $databaseId,
tableId: 'notes',
rowId: id
})
userId:const { rows } = await $tables.listRows({
databaseId: $databaseId,
tableId: 'notes',
queries: [
$Query.equal('author', userId)
]
})
Query.equal covers our author column filter.<script lang="ts" setup>
const { addNote, noteError, newNote } = useNotes()
</script>
<template>
<form class="mx-auto flex max-w-md gap-2">
<textarea
autocomplete="note"
type="text"
name="note"
v-model="newNote"
placeholder="Enter a note"
class="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-500"
/>
<button
type="submit"
@click.prevent="addNote"
class="rounded-md bg-black px-4 py-2 text-sm font-medium text-white hover:bg-gray-800"
>
Add Note
</button>
</form>
<p v-if="noteError" class="mx-auto mt-2 max-w-md text-sm text-red-600">
{{ noteError }}
</p>
</template>
noteError.And we display our notes to each user.
<script lang="ts" setup>
const { notes, removeNote, resetNotes, getNotes } = useNotes()
onMounted(getNotes)
onUnmounted(resetNotes)
</script>
<template>
<p class="mb-4 text-center text-lg font-semibold text-gray-800">Notes</p>
<template v-if="notes.loading">
<Loading />
</template>
<template v-else-if="notes.data.length">
<ul
class="mx-auto mb-6 grid max-w-4xl grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
>
<li
v-for="note in notes.data"
:key="note.id"
class="relative min-h-45 -rotate-1 rounded-sm border border-yellow-200 bg-yellow-200 p-4 shadow-md"
>
<div
class="absolute left-1/2 top-0 h-5 w-16 -translate-x-1/2 -translate-y-1/2 -rotate-3 bg-yellow-100/80 shadow-sm"
/>
<p class="pb-12 text-sm leading-6 text-gray-800">
{{ note.note }}
</p>
<button
type="button"
class="absolute bottom-3 right-3 not-[]:px-3 py-1 hover:opacity-50"
@click="removeNote(note.id)"
>
🗑️
</button>
</li>
</ul>
</template>
<template v-else>
<p class="mb-6 text-center text-gray-500">No notes yet. Add one below!</p>
</template>
<AddNote />
</template>
We can login or register, and each user has their own todos!





Repo: GitHub
📝 I don’t have a demo because the free Appwrite project will pause after a certain time, but this demo should be pretty easy to clone.
Happy coding!
Jonathan Gamble has been an avid web programmer for more than 20 years. He has been building web applications as a hobby since he was 16 years old, and he received a post-bachelor’s in Computer Science from Oregon State. His real passions are language learning and playing rock piano, but he never gets away from coding. Read more from him at https://code.build/.