Nuxt Todo App with Firebase

TL;DR#
This is the Nuxt version of the Firebase Todo App. It uses the latest Nuxt 4 with useFetch, composables, app directory, error handling, and firebase lite for the server.
Firebase Setup#
Put your firebase config in your .env file:
VITE_FIREBASE_CONFIG='{"apiKey":"...","authDomain":"...","projectId":"...","appId":"..."}'
app/utils/firebase.ts#
This is the browser version.
import { getApp, getApps, initializeApp, type FirebaseOptions } from 'firebase/app'
import { getAuth } from 'firebase/auth'
import { getFirestore } from 'firebase/firestore'
// import your .env variable
// VITE_FIREBASE_CONFIG={YOUR FIREBASE CONFIG}
// make sure the Firebase keys are in Quotes ""
const firebaseConfig = JSON.parse(import.meta.env.VITE_FIREBASE_CONFIG) as FirebaseOptions
const app = getApps().length
? getApp()
: initializeApp(firebaseConfig)
export const auth = getAuth(app)
export const db = getFirestore(app)
server/utils/firebase-lite.ts#
This is the server version, notice it uses the /lite directory for importing.
import { getApp, getApps, initializeApp, type FirebaseOptions } from 'firebase/app'
import { getFirestore } from 'firebase/firestore/lite'
// import your .env variable
// VITE_FIREBASE_CONFIG={YOUR FIREBASE CONFIG}
// make sure the Firebase keys are in Quotes ""
const firebaseConfig = JSON.parse(process.env.VITE_FIREBASE_CONFIG!) as FirebaseOptions
const app = getApps().length
? getApp()
: initializeApp(firebaseConfig)
export const serverDB = getFirestore(app)
shared/types/models.d.ts#
These are the user, todo, and About types used throughout the app.
export {}
declare global {
type UserType = {
displayName: string | null
photoURL: string | null
uid: string
email: string | null
}
type UserState = {
loading: boolean
data: UserType | null
error: string | null
}
type TodoDoc = {
id: string
uid: string
text: string
complete: boolean
createdAt: Date
}
type AboutDoc = {
name: string
description: string
}
}
Authentication#
We must login to use app.
app/composables/auth.ts#
import { FirebaseError } from 'firebase/app'
import { GoogleAuthProvider, onIdTokenChanged, signInWithPopup, signOut } from 'firebase/auth'
import { inject, provide, shallowRef, watchPostEffect, type ShallowRef } from 'vue'
import { auth } from '../utils/firebase'
// User context key
const USER_KEY = Symbol('user')
export const setUser = () => {
const user = shallowRef<UserState>({
loading: true,
data: null,
error: null
})
// Create user listener
watchPostEffect((onCleanup) => {
const unsubscribe = onIdTokenChanged(auth, (currentUser) => {
// not logged in
if (!currentUser) {
user.value = { loading: false, data: null, error: null }
return
}
// logged in
const { displayName, photoURL, uid, email } = currentUser
user.value = {
loading: false,
data: { displayName, photoURL, uid, email },
error: null
}
}, (error) => {
user.value = { loading: false, data: null, error: error.message }
})
onCleanup(unsubscribe)
})
provide(USER_KEY, user)
return user
}
export const getUser = () => {
const user = inject<ShallowRef<UserState> | null>(USER_KEY, null)
if (!user) throw new Error('User state has not been provided')
return user
}
export const loginWithGoogle = async () => {
try {
await signInWithPopup(auth, new GoogleAuthProvider())
return { error: null }
} catch (error) {
if (error instanceof FirebaseError) {
return { error: error.message }
}
throw error
}
}
export const logout = async () => {
try {
await signOut(auth)
return { error: null }
} catch (error) {
if (error instanceof FirebaseError) {
return { error: error.message }
}
throw error
}
}
app/components/home.vue#
Home handles login states.
<script setup lang="ts">
const user = setUser()
const actionError = ref<string | null>(null)
const onLogin = async () => {
actionError.value = null
const result = await loginWithGoogle()
actionError.value = result.error
}
const onLogout = async () => {
actionError.value = null
const result = await logout()
actionError.value = result.error
}
</script>
<template>
<section class="flex flex-col gap-3 p-5 items-center">
<p v-if="user.error || actionError" class="text-red-600" role="alert">
{{ user.error || actionError }}
</p>
<p v-if="user.loading">Loading...</p>
<template v-else-if="user.data">
<Profile />
<button class="border bg-blue-600 text-white w-fit p-3 rounded-lg font-semibold" @click="onLogout">
Logout
</button>
<hr />
<Todos />
</template>
<button class="bg-red-600 text-white font-semibold p-2" @click="onLogin" v-else>
Signin with Google
</button>
</section>
</template>
app/components/profile.vue#
Profile shows name, id, and photo.
<script setup lang="ts">
const user = getUser()
</script>
<template>
<div class="flex flex-col justify-center items-center gap-3" v-if="user.data">
<h3 class="text-2xl font-bold">Hi {{ user.data.displayName }}!</h3>
<img :src="user.data.photoURL" height="100" width="100" alt="user avatar" v-if="user.data.photoURL" />
<p>Your userID is {{ user.data.uid }}</p>
</div>
</template>
Todos#
Let's handle our tasks.
app/composables/todos.ts#
Now me must handle displaying todos.
import {
collection,
deleteDoc,
doc,
FirestoreError,
onSnapshot,
orderBy,
query,
serverTimestamp,
setDoc,
Timestamp,
updateDoc,
where,
type FirestoreDataConverter,
} from 'firebase/firestore'
import { ref, watchPostEffect } from 'vue'
import { auth, db } from '../utils/firebase'
// Only used to create example texts -- DO NOT USE IN PRODUCTION
export const generateText = () => {
return doc(collection(db, 'todos'))
.id
.substring(0, 10)
.toLowerCase()
}
const todoConverter: FirestoreDataConverter<TodoDoc> = {
toFirestore(todo) {
return todo
},
fromFirestore(snapshot) {
// server optimistic date updates
const data = snapshot.data({
serverTimestamps: 'estimate'
})
// correctly use the date type
const createdAt = data.createdAt as Timestamp
return {
id: snapshot.id,
uid: data.uid,
text: data.text,
complete: data.complete,
createdAt: createdAt.toDate()
}
}
}
export const useTodos = () => {
const user = getUser()
const todos = ref<{
data: TodoDoc[]
loading: boolean
error: FirestoreError | null
}>({
data: [],
loading: true,
error: null
})
watchPostEffect((onCleanup) => {
// Must be logged in
const currentUser = user.value.data
if (!currentUser) {
todos.value = {
loading: user.value.loading,
data: [],
error: null
}
return
}
todos.value = { loading: true, data: [], error: null }
const unsubscribe = onSnapshot(
query(
collection(db, 'todos'),
where('uid', '==', currentUser.uid),
orderBy('createdAt')
).withConverter(todoConverter),
(snapshot) => {
const data = snapshot.docs.map(
(document) => document.data()
)
if (import.meta.dev) {
console.log(data)
}
todos.value = { loading: false, data, error: null }
},
(error) => {
todos.value = { loading: false, data: [], error }
}
)
onCleanup(unsubscribe)
})
return todos
}
export const addTodo = async (text: string) => {
const user = auth.currentUser
if (!user) {
return { error: 'No user' }
}
try {
await setDoc(
doc(collection(db, 'todos')),
{
uid: user.uid,
text,
complete: false,
createdAt: serverTimestamp()
}
)
return { error: null }
} catch (error) {
if (error instanceof FirestoreError) {
return { error: error.message }
}
throw error
}
}
export const updateTodo = async (id: string, newStatus: boolean) => {
try {
await updateDoc(
doc(db, 'todos', id),
{
complete: newStatus,
updatedAt: serverTimestamp()
}
)
return { error: null }
} catch (error) {
if (error instanceof FirestoreError) {
return { error: error.message }
}
throw error
}
}
export const deleteTodo = async (id: string) => {
try {
await deleteDoc(
doc(db, 'todos', id)
)
return { error: null }
} catch (error) {
if (error instanceof FirestoreError) {
return { error: error.message }
}
throw error
}
}
app/components/todos.vue#
The todo list presents loading, error, empty, and populated states, as well as the add form.
<script setup lang="ts">
const todos = useTodos()
</script>
<template>
<p v-if="todos.loading">Loading...</p>
<p v-else-if="todos.error" class="text-red-600" role="alert">{{ todos.error.message }}</p>
<div class="grid grid-cols-[auto,auto,auto,auto] gap-3 justify-items-start" v-else-if="todos.data.length">
<template v-for="todo in todos.data" :key="todo.id">
<TodoItem :todo="todo" />
</template>
</div>
<p class="font-bold" v-else>
Add your first todo item!
</p>
<TodoForm />
</template>
app/components/todo-form.vue#
Handle a new todo.
<script setup lang="ts">
const text = ref(generateText())
const error = ref<string | null>(null)
const onSubmit = async () => {
error.value = null
const result = await addTodo(text.value)
error.value = result.error
if (!result.error) text.value = generateText()
}
</script>
<template>
<form class="flex gap-3 items-center justify-center mt-5" @submit.prevent="onSubmit">
<input v-model="text" class="border p-2" name="task" required />
<button class="border p-2 rounded-md text-white bg-sky-700" type="submit">
Add Task
</button>
</form>
<p v-if="error" class="text-red-600" role="alert">{{ error }}</p>
</template>
app/components/todo-item.vue#
Each item can toggle its completion state or be removed.
<script setup lang="ts">
const { todo } = defineProps<{ todo: TodoDoc }>()
const actionError = ref<string | null>(null)
const runAction = async (action: () => Promise<{ error: string | null }>) => {
actionError.value = null
const result = await action()
actionError.value = result.error
}
</script>
<template>
<span :class="todo.complete ? 'line-through text-green-700' : ''">
{{ todo.text }}
</span>
<span :class="todo.complete ? 'line-through text-green-700' : ''">
{{ todo.id }}
</span>
<button type="button" @click="runAction(() => updateTodo(todo.id, !todo.complete))" v-if="todo.complete">
✔️
</button>
<button type="button" @click="runAction(() => updateTodo(todo.id, !todo.complete))" v-else>
❌
</button>
<button type="button" @click="runAction(() => deleteTodo(todo.id))">
🗑
</button>
<p v-if="actionError" class="col-span-4 text-red-600" role="alert">{{ actionError }}</p>
</template>
Server-side Firestore#
The about page fetches from the server.
server/api/about.get.ts#
This is a server only path for the fetch to get the about doc.
import { doc, getDoc } from 'firebase/firestore/lite'
import { serverDB } from '../utils/firebase-lite';
import * as v from "valibot";
// Valibot is smaller and faster than Zod, use Valibot
const AboutDocSchema = v.object({
name: v.string(),
description: v.string()
});
export default defineEventHandler(async () => {
const aboutSnap = await getDoc(
doc(serverDB, 'about', 'ZlNJrKd6LcATycPRmBPA')
)
if (!aboutSnap.exists()) {
throw createError({ statusCode: 404, statusMessage: 'Document does not exist' })
}
// Verifiy document with Valibot
// Only necessary for doubts on doc integrity
const result = v.safeParse(AboutDocSchema, aboutSnap.data());
if (!result.success) {
throw createError({ statusCode: 500, statusMessage: "Malformed About document" })
}
return result.output;
})
app/composables/about.ts#
We read the about doc through REST get call.
export const useAbout = () => useFetch<AboutDoc>('/api/about', { key: 'about' })
app/components/about-page.vue#
<script setup lang="ts">
const { data, error } = await useAbout()
</script>
<template>
<p v-if="error" class="text-red-600" role="alert">{{ error.message }}</p>
<div v-else-if="data" class="flex items-center justify-center my-5">
<div class="border w-[400px] p-5 flex flex-col gap-3">
<h1 class="text-3xl font-semibold">{{ data.name }}</h1>
<p>{{ data.description }}</p>
</div>
</div>
</template>
App Structure#
We must handle layouts and navigation.
app/app.vue#
Make sure layouts are enabled.
<template>
<main>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</main>
</template>
app/layouts/default.vue#
And our shared navigation.
<template>
<main>
<slot />
<nav class="flex gap-3 justify-center mt-5">
<NuxtLink to="/">Home</NuxtLink>
<NuxtLink to="/about">About</NuxtLink>
</nav>
</main>
</template>
app/pages/index.vue#
We add a title tag.
<script setup lang="ts">
useHead({ title: 'Nuxt Firebase Todo App' })
</script>
<template>
<Home />
</template>
app/pages/about.vue#
We show the about page.
<template>
<AboutPage />
</template>
J
Comments
Share a thought or join the conversation.
Sign In to comment or reply.