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.
TL;DR#
This is my Firebase Todo app built with Nuxt 3. It does not use any external packages; it uses template
and script
tags and 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#
First, you must set up your Firebase API keys. Put them in a .env
file, and create a string from the JSON object.
.env#
NUXT_PUBLIC_FIREBASE_CONFIG={"apiKey":"...","authDomain":"..."...}
Notice the keys have double quotes around them, and there is no semi-colon at the end.
nuxt.config.ts#
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: {
FIREBASE_CONFIG: JSON.parse(
process.env.NUXT_PUBLIC_FIREBASE_CONFIG!
),
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.
firebase.ts#
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) {
initializeApp(config.public.FIREBASE_CONFIG)
}
const auth = getAuth()
const db = getFirestore()
return {
provide: {
auth,
db
}
}
})
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
}
User Hook#
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
return
}
const { displayName, photoURL, uid, email } = _user
user.value = { displayName, photoURL, uid, email }
})
})
onUnmounted(unsubscribe)
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 multiple components without checking for the first loading instance, the observable will get reran every time. While our app will still function, it wastes 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 find the code not as clean, but it is better.
Home Component#
Our Home component calls the shared user composable, checks for a user, and allows you to log in or log out depending on the state.
<script setup lang="ts">
const user = useUser()
</script>
<template>
<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"
@click="logout">
Logout
</button>
<hr />
<Todos />
</template>
<button class="bg-red-600 text-white font-semibold p-2"
@click="loginWithGoogle"
v-else>
Signin with Google
</button>
</section>
</template>
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()
</script>
<template>
<div class="flex flex-col justify-center items-center gap-3"
v-if="user">
<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>
</div>
</template>
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
target.reset()
if (!uid) {
throw 'No user!'
}
const { $db } = useNuxtApp()
setDoc(doc(collection($db, 'todos')), {
uid,
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 {
...data,
created: created.toDate(),
id: doc.id
}
}) as TodoItem[]
}
useTodos#
This hook creates a todos
signal and depends on the user
signal to track whether the user is logged in. We clean up the unsubscribe
method to keep our memory in check by returning it in watchEffect
. This also re-runs when the user
signal changes.
export const useTodos = () => {
const runtimeConfig = useRuntimeConfig()
const { $db } = useNuxtApp()
const user = useUser()
const todos = ref<{
data: TodoItem[]
loading: boolean
}>({
data: [],
loading: true
})
const userData = user.value
if (!userData) {
todos.value.loading = false
todos.value.data = []
return todos
}
watchEffect((onCleanup) => {
const unsubscribe = onSnapshot(
// query realtime todo list
query(
collection($db, 'todos'),
where('uid', '==', userData.uid),
orderBy('createdAt')
), (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) {
console.log(data)
}
// add to store
todos.value.data = data
})
onCleanup(unsubscribe)
})
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()
</script>
<template>
<div class="grid grid-cols-[auto,auto,auto,auto] gap-3 justify-items-start" v-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>
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 }>()
</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="updateTodo(todo.id, !todo.complete)"
v-if="todo.complete">
✔️
</button>
<button type="button" @click="updateTodo(todo.id, !todo.complete)"
v-else>
❌
</button>
<button type="button" @click="deleteTodo(todo.id)">
đź—‘
</button>
</template>
About Page#
I also created a quick about page to fetch data from the server and hydrate it to the client.
useAbout#
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) {
console.log(data)
}
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 must create an endpoint and use useFetch()
hook instead.
<script setup lang="ts">
const data = await useAbout()
</script>
<template>
<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>
</div>
</div>
</template>
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!
J
Repo: GitHub
Demo: Vercel Edge