Login

Nuxt Todo App with Firebase

J
Jonathan Gamble
Published Updated 17 min read


Nuxt Todo App Demo

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:

Bash
VITE_FIREBASE_CONFIG='{"apiKey":"...","authDomain":"...","projectId":"...","appId":"..."}'

app/utils/firebase.ts#

This is the browser version.

TypeScript
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.

TypeScript
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.

TypeScript
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#

TypeScript
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.

Vue
<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.

Vue
<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.

TypeScript
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.

Vue
<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.

Vue
<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.

Vue
<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.

TypeScript
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.

TypeScript
export const useAbout = () => useFetch<AboutDoc>('/api/about', { key: 'about' })

app/components/about-page.vue#

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.

Vue
<template>
    <main>
        <NuxtLayout>
            <NuxtPage />
        </NuxtLayout>
    </main>
</template>

app/layouts/default.vue#

And our shared navigation.

Vue
<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.

Vue
<script setup lang="ts">
useHead({ title: 'Nuxt Firebase Todo App' })
</script>

<template>
    <Home />
</template>

app/pages/about.vue#

We show the about page.

Vue
<template>
    <AboutPage />
</template>

J

Comments

Share a thought or join the conversation.

Sign In to comment or reply.