Qwik City Todo App with Firebase
TL;DR#
A Qwik City todo app with Google sign-in, live Firestore todos, and a server-loaded About page.

.env#
Add your Firebase web app configuration as a JSON string in the .env file:
PUBLIC_FIREBASE_CONFIG={"apiKey":"...","authDomain":"...",...}
app.d.ts#
Defines the shared user, todo, and About document types used across 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
};
type TodoDoc = {
id: string;
uid: string;
text: string;
complete: boolean;
createdAt: Date;
};
type AboutDoc = {
name: string;
description: string;
};
}
lib/firebase.ts#
Initializes the browser Firebase app and exports its Auth and Firestore clients.
import { getApp, getApps, initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
// import your .env variable
// PUBLIC_FIREBASE_CONFIG={YOUR FIREBASE CONFIG}
// make sure the Firebase keys are in Quotes ""
const firebase_config = JSON.parse(
import.meta.env.PUBLIC_FIREBASE_CONFIG
);
// initialize firebase
export const app = getApps().length
? getApp()
: initializeApp(firebase_config);
export const auth = getAuth(app);
export const db = getFirestore(app);
lib/firebase-lite.ts#
Creates the Firestore Lite client used by the server-loaded About page.
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore/lite";
const firebase_config = JSON.parse(
import.meta.env.PUBLIC_FIREBASE_CONFIG
);
const serverApp = initializeApp(firebase_config);
// !!! This is imported from `firestore/lite` directory for smaller server imports
export const serverDB = getFirestore(serverApp);
lib/auth.ts#
Handles Google sign-in, sign-out, and shared user state.
import {
createContextId,
useContext,
useContextProvider,
useStore,
useVisibleTask$
} from '@builder.io/qwik';
import {
GoogleAuthProvider,
onIdTokenChanged,
signInWithPopup,
signOut,
type User
} from 'firebase/auth';
import { FirebaseError } from 'firebase/app';
import { auth } from './firebase';
// User context key
const USER_CONTEXT = createContextId<{ value: UserState }>('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;
}
};
export const setUser = () => {
const user = useStore<{ value: UserState }>({
value: {
loading: true,
data: null
}
});
// Share the same user state with child components
useContextProvider(USER_CONTEXT, user);
// Create user listener
useVisibleTask$(({ cleanup }) => {
const unsubscribe = onIdTokenChanged(auth, (_user: User | null) => {
// Not logged in
if (!_user) {
user.value = {
loading: false,
data: null
};
return;
}
// Logged in
const { displayName, photoURL, uid, email } = _user;
user.value = {
loading: false,
data: { displayName, photoURL, uid, email }
};
});
// Remove the listener when the layout is destroyed
cleanup(unsubscribe);
});
return user;
};
export const getUser = () => {
return useContext(USER_CONTEXT);
};
lib/todos.ts#
Subscribes to todos for the current user and provides create, update, and delete functions.
import { useStore, useVisibleTask$ } from '@builder.io/qwik';
import {
collection,
deleteDoc,
doc,
FirestoreError,
onSnapshot,
orderBy,
query,
serverTimestamp,
setDoc,
Timestamp,
type FirestoreDataConverter,
updateDoc,
where
} from 'firebase/firestore';
import { getUser } from './auth';
import { auth, db } from './firebase';
// Only used to create example texts -- DO NOT USE IN PRODUCTION
export const generateText = () =>
doc(collection(db, 'todos'))
.id
.substring(0, 10)
.toLowerCase();
const todoConverter: FirestoreDataConverter<TodoDoc> = {
toFirestore(todo) {
return todo;
},
fromFirestore(snapshot): TodoDoc {
// Use an estimated date for pending server timestamps
const data = snapshot.data({
serverTimestamps: 'estimate'
});
// Convert the Firestore timestamp to a Date
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 = useStore<{
value: {
data: TodoDoc[];
loading: boolean;
error: string | null;
};
}>({
value: {
data: [],
loading: true,
error: null
}
});
useVisibleTask$(({ track, cleanup }) => {
// Reconnect when the logged-in user changes
const currentUser = track(() => user.value.data);
// Must be logged in
if (!currentUser) {
todos.value = {
data: [],
loading: false,
error: null
};
return;
}
todos.value = {
data: [],
loading: true,
error: null
};
// Subscribe to this user's todos
const unsubscribe = onSnapshot(
query(
collection(db, 'todos'),
where('uid', '==', currentUser.uid),
orderBy('createdAt')
).withConverter(todoConverter),
(snapshot) => {
const data = snapshot.docs.map((item) => item.data());
if (import.meta.env.DEV) {
console.log(data);
}
todos.value = {
data,
loading: false,
error: null
};
},
(error) => {
todos.value = {
data: [],
loading: false,
error: error.message
};
}
);
// Remove the previous listener when the user changes
cleanup(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;
}
};
lib/about.ts#
Fetches the About document and validates its fields with Valibot.
import { doc, getDoc } from "firebase/firestore/lite";
import * as v from "valibot";
import { serverDB } from "./firebase-lite";
// Valibot is smaller and faster than Zod, use Valibot
const AboutDocSchema = v.object({
name: v.string(),
description: v.string()
});
export const getAbout = async () => {
const aboutSnap = await getDoc(doc(serverDB, "/about/ZlNJrKd6LcATycPRmBPA"));
if (!aboutSnap.exists()) {
throw "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 "Malformed About document";
}
return result.output;
};
components/home.tsx#
Shows a loading message, sign-in button, or profile based on authentication state.
import { component$ } from "@builder.io/qwik";
import { setUser } from "~/lib/auth";
import Profile from "./profile";
import { Loading, Login } from "./helpers";
export default component$(() => {
const user = setUser();
return (
<div class="text-center">
<h1 class="text-3xl font-semibold my-3">Qwik Firebase Todo App</h1>
{user.value.loading ? <Loading /> : user.value.data ? <Profile /> : <Login />}
</div>
);
});
components/helpers.tsx#
Provides the loading, sign-in, and sign-out controls.
import { loginWithGoogle, logout } from "~/lib/auth";
export const Loading = () => {
return <p>Loading...</p>;
};
export const Login = () => {
return <button type="button" class="border p-2 rounded-md text-white bg-red-600" onClick$={() => loginWithGoogle()}>
Signin with Google
</button>
};
export const Logout = () => {
return <p>
<button type="button" class="border p-2 rounded-md text-white bg-lime-600" onClick$={() => logout()}>
Logout
</button>
</p>;
};
components/profile.tsx#
Displays the signed-in user profile and todo list.
import { component$ } from '@builder.io/qwik';
import { getUser } from '~/lib/auth';
import Todos from './todos';
import { Logout } from './helpers';
export default component$(() => {
const user = getUser();
if (!user.value.data) {
return;
}
const { displayName, photoURL, uid } = user.value.data;
return (
<div class="flex flex-col gap-3 items-center">
<h3 class="font-bold">Hi {displayName}!</h3>
<img src={photoURL || ''} width="100" height="100" alt="user avatar" />
<p>Your userID is {uid}</p>
<Logout />
<Todos />
</div>
);
});
components/todos.tsx#
Renders the live todo list and the form for adding a task.
import { $, component$, useSignal } from "@builder.io/qwik";
import { useTodos, addTodo, generateText } from "~/lib/todos";
import { Todo } from "./todo-item";
// todo component
export default component$(() => {
const todos = useTodos();
return (
<div>
<div class="flex flex-col gap-3">
{todos.value.loading ? (
<p>Loading todos...</p>
) : todos.value.error ? (
<p role="alert" class="text-red-600">
{todos.value.error}
</p>
) : todos.value.data.length ? (
todos.value.data.map((todo) => <Todo key={todo.id} todo={todo} />)
) : (
<p><b>Add your first todo item!</b></p>
)}
</div>
<TodoForm />
</div>
);
});
// todo form
export const TodoForm = component$(() => {
const text = useSignal(generateText());
const error = useSignal<string | null>(null);
const onSubmit = $(async () => {
error.value = null;
const submittedText = text.value;
const result = await addTodo(submittedText);
error.value = result.error;
if (!result.error) {
text.value = generateText();
}
});
return (
<div class="mt-5">
<form
class="flex gap-3 items-center justify-center"
preventdefault:submit
onSubmit$={onSubmit}
>
<input
class="border p-2 rounded-lg"
bind:value={text}
/>
<button
class="border p-2 rounded-lg bg-purple-600 text-white font-semibold"
type="submit"
>
Add Task
</button>
</form>
{error.value && (
<p role="alert" class="mt-2 text-center text-red-600">
{error.value}
</p>
)}
</div>
);
});
components/todo-item.tsx#
Displays one todo with controls to toggle or delete it.
import { component$, useSignal } from "@builder.io/qwik";
import { deleteTodo, updateTodo } from "~/lib/todos";
// each todo item
export const Todo = component$(({ todo }: { todo: TodoDoc }) => {
const error = useSignal<string | null>(null);
return (
<div class="grid grid-cols-[auto,auto,auto,auto] gap-3 items-center justify-items-start">
<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" onClick$={async () => {
const result = await updateTodo(todo.id, !todo.complete);
error.value = result.error;
}}>{todo.complete ? '✔️' : '❌'}</button>
<button type="button" onClick$={async () => {
const result = await deleteTodo(todo.id);
error.value = result.error;
}}> 🗑 </button>
{error.value && <p role="alert" class="col-span-4 text-red-600">{error.value}</p>}
</div>
);
});
routes/index.tsx#
Renders the home page and sets its document metadata.
import type { DocumentHead } from '@builder.io/qwik-city';
import Home from '~/components/home';
export default () => <Home />;
export const head: DocumentHead = {
title: 'Qwik Firebase Todo App',
meta: [
{
name: 'description',
content: 'Qwik Firebase Todo App'
},
],
};
routes/about/index.tsx#
Loads and renders the About document through a route loader.
import { component$ } from "@builder.io/qwik";
import { routeLoader$ } from "@builder.io/qwik-city";
import { getAbout } from "~/lib/about";
export const useAboutPage = routeLoader$(async () => {
return await getAbout();
});
export default component$(() => {
const about = useAboutPage();
return (
<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">{about.value.name}</h1>
<p>{about.value.description}</p>
</div>
</div>
);
});
routes/layout.tsx#
Wraps each route with navigation links.
import { component$, Slot } from '@builder.io/qwik';
import { Link } from '@builder.io/qwik-city';
export default component$(() => {
return (
<>
<Slot />
<nav class="flex gap-3 justify-center mt-5">
<Link href="/">Home</Link>
<Link href="/about">About</Link>
</nav>
</>
);
});
J
Comments
Share a thought or join the conversation.
Sign In to comment or reply.