> Nuxt Todo App with Firebase

Nuxt Todo App with Firebase

18 min read

Jonathan Gamble

jdgamble555 on Wednesday, April 3, 2024 (last modified on Wednesday, April 3, 2024)

I have read articles and watched many tutorials on Vue, but this is the first Vue or Nuxt app that I have actually built myself. Nuxt 3 is amazing and definitely worth your time. I learn something new about my old tech stack whenever I build something with a new language, framework, or database. Nuxt 3 is no different.


This is my Firebase Todo app built with Nuxt 3. It does not use any external packages, it uses template and script tags, and it is clean and simple. Because the data needs authentication, it does not use SSR. It can be hosted on Cloudflare, Vercel Edge Functions, any Edge Network, and other NodeJS environments.

Environment Variables#

The first thing you need to setup is your Firebase API keys. Put them in a .env file, and create a string from the JSON object.



Notice the keys have double quotes around them, and there is no semi-colon at the end.


Next, edit your Nuxt configuration file to grab the public firebase configuration and set it as a variable.

	// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
  devtools: { enabled: true },
  runtimeConfig: {
    public: {
      dev: process.env.NODE_ENV !== 'production'
  css: ['~/assets/css/main.css'],
  postcss: {
    plugins: {
      tailwindcss: {},
      autoprefixer: {},

My app also uses Tailwind, and I created a reusable dev variable, but your version may be simpler.

Firebase Plugin#

Next, create a Firebase plugin in the plugins directory.


This allows you to reuse your Firebase globals anywhere in your app. By returning the variables, you get the correct types when you use them.

	import { getApps, initializeApp } from "firebase/app"
import { getAuth } from "firebase/auth"
import { getFirestore } from "firebase/firestore"

export default defineNuxtPlugin(() => {

    const config = useRuntimeConfig()

    // initialize and login
    if (!getApps().length) {

    const auth = getAuth()
    const db = getFirestore()

    return {
        provide: {

User Composable#

Create a user composable called user.ts in the composables directory. This is where you put your libraries and custom hooks like in other frameworks.

Login Functions#

	export const loginWithGoogle = async () => {
    const { $auth } = useNuxtApp()
    await signInWithPopup($auth, new GoogleAuthProvider())

export const logout = async () => {
    const { $auth } = useNuxtApp()
    await signOut($auth)

Notice you can use the useNuxtapp() to get your plugin globals. They will always start with a $.

You should also store your UserType here.

	export type UserType = {
    displayName: string | null
    photoURL: string | null
    uid: string
    email: string | null


Now, you must create a custom hook to check for a logged-in user. This will subscribe to onIdTokenChanged, which is similar to onAuthStateChanged but more powerful.

	export const useUser = () => {

    const { $auth } = useNuxtApp()

    const user = useState<UserType | null>('user', () => null)

    const initialLoad = useState<boolean>('user-initial-load', () => true)

    if (!initialLoad.value) {
        return user

    let unsubscribe: Unsubscribe = () => {}

    onMounted(() => {
        initialLoad.value = false
        unsubscribe = onIdTokenChanged($auth, (_user: User | null) => {
            if (!_user) {
                user.value = null
            const { displayName, photoURL, uid, email } = _user
            user.value = { displayName, photoURL, uid, email }


    return user

Here, we can store our variables using useState instead of ref. This is wonderful as it allows us to save and retrieve variables only once. If we call this hook in more than one component without checking for the first loading instance, the observable will get reran every time. While our app will still function, it will be wasting client memory and resources. useState just checks for a provider and creates one with the default value if it does not exist. This is extremely unique to Vue. We also have to unsubscribe to the observable, a feat most Firebase articles seem to ignore. Again, we want to save our client app’s memory. We are not watching the effect of another variable here, so mounting and unmounting are the correct usage. I do find the code not as clean, but it is better code.

Home Component#

Our Home component calls the shared user composable, checks for a user, and allows you to login or logout depending on the state.

	<script setup lang="ts">

const user = useUser()


    <section class="flex flex-col gap-3 p-5 items-center">
        <template v-if="user">
            <Profile />
            <button class="border bg-blue-600 text-white w-fit p-3 rounded-lg font-semibold"
            <hr />
            <Todos />
        <button class="bg-red-600 text-white font-semibold p-2"
            Signin with Google

I prefer using templates to check for conditionals or loops when creating an additional div is unnecessary. There should be nothing crazy here.

Profile Component#

Our profile component is similar and displays the user information when a user is logged in. There is nothing extraordinary here. The user hook just works automatically, and you don’t have to think about it.

	<script setup lang="ts">

const user = useUser()


    <div class="flex flex-col justify-center items-center gap-3"
        <h3 class="text-2xl font-bold">Hi {{ user.displayName }}!</h3>
        <img :src="user.photoURL" height="100" width="100" alt="user avatar"
          v-if="user?.photoURL" />
        <p>Your userID is {{ user.uid }}</p>

Todos Composable#

I also created a todos hook, or composable. It is not shared, but it is dependent on the user. First, we have to set up our types, add a todo, delete a todo, and update a todo.

Add, Update, Delete#

	export const addTodo = async (e: Event) => {

    const uid = useUser().value?.uid

    // get and reset form
    const target = e.target as HTMLFormElement
    const form = new FormData(target)
    const { task } = Object.fromEntries(form)

    // reset form

    if (!uid) {
        throw 'No user!'

    const { $db } = useNuxtApp()

    setDoc(doc(collection($db, 'todos')), {
        text: task,
        complete: false,
        created: serverTimestamp()

export const updateTodo = (id: string, newStatus: boolean) => {
    const { $db } = useNuxtApp()
    updateDoc(doc($db, 'todos', id), { complete: newStatus })

export const deleteTodo = (id: string) => {
    const { $db } = useNuxtApp()
    deleteDoc(doc($db, 'todos', id))

This is all standard Firebase code. Notice you reuse the $db from the useNuxtApp(). I also find that different Frameworks need a different type for the event in a form submit. Here Event works.

Type and snapData#

I am also reusing my snapToData function to get the Todo data correctly. The type is exported as well.

	export interface TodoItem {
    id: string
    text: string
    complete: boolean
    created: Date
    uid: string

export const snapToData = (
    q: QuerySnapshot<DocumentData, DocumentData>
) => {

    // creates todo data from snapshot
    if (q.empty) {
        return []
    return q.docs.map((doc) => {
        const data = doc.data({
            serverTimestamps: 'estimate'
        const created = data.created as Timestamp;
        return {
            created: created.toDate(),
            id: doc.id
    }) as TodoItem[]


This hook will look more similar to my other versions. I am using watchEffect here since the todos subscription depends on the user ID. It must keep track of whether or not the user is logged in. We cleanup the unsubscribe method to keep our memory in check.

	export const useTodos = () => {

    const runtimeConfig = useRuntimeConfig()

    const { $db } = useNuxtApp()

    const user = useUser()

    const todos = ref<{
        data: TodoItem[]
        loading: boolean
        data: [],
        loading: true

    watchEffect((onCleanup) => {

        const userData = user.value

        if (!userData) {
            todos.value.loading = false
            todos.value.data = []

        const unsubscribe = onSnapshot(

            // query realtime todo list
                collection($db, 'todos') as CollectionReference<TodoItem[]>,
                where('uid', '==', userData.uid),
            ), (q) => {

                // toggle loading
                todos.value.loading = false

                // get data, map to todo type
                const data = snapToData(q)

                 * Note: Will get triggered 2x on add 
                 * 1 - for optimistic update
                 * 2 - update real date from server date

                // print data in dev mode
                if (runtimeConfig.public.dev) {

                // add to store
                todos.value.data = data


    return todos

I must admit it is fun ignoring semi-colons in Nuxt, as it can be a waste of hard drive space. I have to be like other Vue developers, after all.

Todos Component#

Here I call the todos composable and print them. Signals are way better than observables or svelte stores.

	<script setup lang="ts">

const todos = useTodos()


    <template v-if="todos.data.length">
        <div class="grid grid-cols-[auto,auto,auto,auto] gap-3 justify-items-start" 
            v-for="todo in todos.data"
            <TodoItem :todo="todo" />
    <p class="font-bold" v-else>
        Add your first todo item!
    <TodoForm />

Todo Item#

Finally, we display the individual to-do items and add buttons to change the data.

	<script setup lang="ts">
const { todo } = defineProps<{ todo: TodoItem }>()

    <span :class="todo.complete ? 'line-through text-green-700' : ''">
        {{ todo.text }}
    <span :class="todo.complete ? 'line-through text-green-700' : ''">
        {{ todo.id }}
    <button type="button" @click="updateTodo(todo.id, !todo.complete)"
    <button type="button" @click="updateTodo(todo.id, !todo.complete)"
    <button type="button" @click="deleteTodo(todo.id)">

About Page#

I also created a quick about page to fetch data from the server and hydrate it to the client.


Here is the composable to get the about data.

	import { doc, getDoc } from "firebase/firestore"

type AboutDoc = {
    name: string
    description: string

export const useAbout = async () => {

    // runs on both server and client

    const runtimeConfig = useRuntimeConfig()

    const { $db } = useNuxtApp()

    const aboutSnap = await getDoc(
        doc($db, '/about/ZlNJrKd6LcATycPRmBPA')

    if (!aboutSnap.exists()) {
        throw 'Document does not exist!'

    const data = aboutSnap.data()

    if (runtimeConfig.public.dev) {

    return data as AboutDoc

About Component#

Secondary fetches will be done on the client after the original page load. If you want to fetch server-only, you will need to create an endpoint and use useFetch() hook instead.

	<script setup lang="ts">

const data = await useAbout()


    <div 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>

What did I learn?#

Vue with Nuxt 3 is gorgeous. I found it more similar to AnalogJS than anything else. I love templating and hate JSX, so this was right up my alley. I rarely had to import anything. The developer experience is amazing. I also didn’t have any of the deployment troubles that Angular gave me.

I think I love Nuxt 3!


Repo: GitHub

Demo: Vercel Edge

Related Posts


Get updates and the latest coding blog posts!

© 2024 Code.Build