Login

Analog Todo App with Firebase

J
Jonathan Gamble
Published Updated 22 min read


Analog Firebase Todo App

TL;DR#

An Analog Angular todo app using standalone components, Angular signals, Firebase Authentication, Firestore, Tailwind CSS, and an SSR-loaded About page.

Setup#

Do not install @angular/fire, as it is outdated and unnecessary (for now). We can also simplify importing since firebase itself is a singleton. This is amazing since Angular is signals first.

Firebase Setup#

Create a Firebase project, enable Google Authentication and Firestore, then add the Firebase configuration to .env:

The same config is used by the browser and server Firebase clients.

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

src/app.d.ts#

These global types keep the authentication, todo, and About data contracts consistent across components and loaders.

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

Initialize Firebase once and export the shared browser clients.

TypeScript
import { getApp, getApps, initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';

const firebase_config = JSON.parse(import.meta.env['VITE_FIREBASE_CONFIG']);

// Reuse an existing app during hot reloads and SSR hydration.
export const app = getApps().length
		? getApp()
		: initializeApp(firebase_config);

export const auth = getAuth(app);
export const db = getFirestore(app);

Authentication#

Google sign-in is exposed through an Angular injection token backed by a Firebase auth listener.

The token owns the subscription, and DestroyRef removes it when the application injector is destroyed.

lib/auth.ts#

The listener converts Firebase's User object into the smaller state shape used by the UI.

TypeScript
import {
	DestroyRef,
	InjectionToken,
	inject,
	isDevMode,
	signal,
	type Signal
} from '@angular/core';
import { FirebaseError } from 'firebase/app';
import {
	GoogleAuthProvider,
	type User,
	onIdTokenChanged,
	signInWithPopup,
	signOut
} from 'firebase/auth';
import { auth } from './firebase';

export const USER = new InjectionToken<Signal<{
	loading: boolean,
	data: UserType | null,
	error: Error | null
}>>(
	'user',
	{
		providedIn: 'root',
		factory() {
			const destroy = inject(DestroyRef);

			const user = signal<{
				loading: boolean,
				data: UserType | null,
				error: Error | null
			}>({
				loading: true,
				data: null,
				error: null
			});

			user.update(_user => ({
				..._user,
				loading: true
			}));

			const unsubscribe = onIdTokenChanged(auth,
				(_user: User | null) => {
					// Keep the signal synchronized with login, logout, and token refreshes.
					if (!_user) {
						user.set({
							data: null,
							loading: false,
							error: null
						});
						return;
					}

					const {
						photoURL,
						uid,
						displayName,
						email
					} = _user;

					const data = {
						photoURL,
						uid,
						displayName,
						email
					};

					if (isDevMode()) {
						console.log(data);
					}

					user.set({
						data,
						loading: false,
						error: null
					});
				}, (error) => {
					user.set({
						data: null,
						loading: false,
						error
					});
				}
			);

			destroy.onDestroy(unsubscribe);

			return user;
		}
	}
);

export async function login() {
	try {
		await signInWithPopup(auth, new GoogleAuthProvider());
		return { error: null };
	} catch (error) {
		if (error instanceof FirebaseError) {
			return { error: error.message };
		}
		throw error;
	}
}

export async function logout() {
	try {
		await signOut(auth);
		return { error: null };
	} catch (error) {
		if (error instanceof FirebaseError) {
			return { error: error.message };
		}
		throw error;
	}
}

components/home/home.component.ts#

TypeScript
import { Component, inject, signal } from '@angular/core';
import { ProfileComponent } from '@components/profile/profile.component';
import { TodosComponent } from '@components/todos/todos.component';
import { USER, login, logout } from '@lib/auth';

@Component({
	selector: 'app-home',
	standalone: true,
	imports: [ProfileComponent, TodosComponent],
	templateUrl: './home.component.html'
})
export class HomeComponent {
	user = inject(USER);
	actionError = signal<string | null>(null);

	async signIn() {
		const result = await login();
		this.actionError.set(result.error);
	}

	async signOut() {
		const result = await logout();
		this.actionError.set(result.error);
	}
}

components/home/home.component.html#

HTML
<h1 class="my-3 text-center text-3xl font-semibold">Analog Firebase Todo App</h1>

<section class="flex flex-col items-center gap-3 p-5">
		@if (actionError(); as error) {
		<p role="alert" class="text-red-600">{{ error }}</p>
		}

		@if (user().data) {
		<app-profile />
		<button type="button" class="w-fit rounded-lg border bg-blue-600 p-3 font-semibold text-white"
				(click)="signOut()">
				Logout
		</button>
		<hr class="w-full" />
		<app-todos />
		} @else if (user().loading) {
		<p>Loading...</p>
		} @else if (user().error) {
		<p role="alert" class="text-red-600">{{ user().error?.message }}</p>
		} @else {
		<button type="button" class="bg-red-600 p-2 font-semibold text-white" (click)="signIn()">
				Signin with Google
		</button>
		}
</section>

components/profile/profile.component.ts#

TypeScript
import { Component, inject } from '@angular/core';
import { USER } from '@lib/auth';

@Component({
	selector: 'app-profile',
	standalone: true,
	imports: [],
	templateUrl: './profile.component.html'
})
export class ProfileComponent {
	user = inject(USER);
}

components/profile/profile.component.html#

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

Todos#

Firestore todos are filtered by the signed-in user and updated in real time.

lib/todos.ts#

The Firestore listener is recreated when the authenticated user changes and cleaned up by the Angular effect.

TypeScript
import {
	InjectionToken,
	effect,
	inject,
	isDevMode,
	signal,
	untracked
} from '@angular/core';
import { FirebaseError } from 'firebase/app';
import {
	Timestamp,
	addDoc,
	collection,
	deleteDoc,
	doc,
	onSnapshot,
	orderBy,
	query,
	serverTimestamp,
	updateDoc,
	where,
	type FirestoreDataConverter
} from 'firebase/firestore';
import { USER } from './auth';
import { db } from './firebase';

const todoConverter: FirestoreDataConverter<TodoDoc> = {
	toFirestore(todo) {
		return todo;
	},
	fromFirestore(snapshot) {
		const data = snapshot.data({
			serverTimestamps: 'estimate'
		});
		const createdAt = data['createdAt'] as Timestamp;

		return {
			...data,
			createdAt: createdAt.toDate(),
			id: snapshot.id
		} as TodoDoc;
	}
};

export const TODOS = new InjectionToken(
	'TODOS',
	{
		providedIn: 'root',
		factory() {
			const user = inject(USER);

			const todos = signal<{
				data: TodoDoc[],
				loading: boolean,
				error: FirebaseError | null
			}>({
				data: [],
				loading: true,
				error: null
			});

			effect((onCleanup) => {
				const userData = user().data;

					// Do not open a Firestore listener until a user is available.
				if (!userData) {
					untracked(() => {
						todos.set({
							loading: false,
							data: [],
							error: null
						});
					});
					return;
				}

				const unsubscribe = onSnapshot(
					query(
						collection(db, 'todos'),
						where('uid', '==', userData.uid),
						orderBy('createdAt')
					).withConverter(todoConverter), (q) => {
						const data = q.docs.map((document) => document.data());

						if (isDevMode()) {
							console.log(data);
						}

						todos.set({
							data,
							loading: false,
							error: null
						});
					}, (error) => {
						todos.set({
							loading: false,
							data: [],
							error
						});
					}
				);

				onCleanup(unsubscribe);
			});

			return todos;
		}
	}
);

export const generateText = () =>
	doc(collection(db, 'todos')).id.substring(0, 10).toLowerCase();

export async function addTodo(text: string, currentUser: UserType | null) {
	if (!currentUser) {
		return { error: 'No user' };
	}

	try {
		await addDoc(collection(db, 'todos'), {
			uid: currentUser.uid,
			text,
			complete: false,
			createdAt: serverTimestamp()
		});
		return { error: null };
	} catch (error) {
		if (error instanceof FirebaseError) {
			return { error: error.message };
		}
		throw error;
	}
}

export async function updateTodo(id: string, complete: boolean) {
	try {
		await updateDoc(doc(db, 'todos', id), { complete, updatedAt: serverTimestamp() });
		return { error: null };
	} catch (error) {
		if (error instanceof FirebaseError) {
			return { error: error.message };
		}
		throw error;
	}
}

export async function deleteTodo(id: string) {
	try {
		await deleteDoc(doc(db, 'todos', id));
		return { error: null };
	} catch (error) {
		if (error instanceof FirebaseError) {
			return { error: error.message };
		}
		throw error;
	}
}

components/todos/todos.component.ts#

This component only coordinates the todo signal, list item, and form components.

TypeScript
import { Component, inject } from '@angular/core';
import { TODOS } from '@lib/todos';
import { TodoItemComponent } from '../todo-item/todo-item.component';
import { TodoFormComponent } from '../todo-form/todo-form.component';

@Component({
	selector: 'app-todos',
	standalone: true,
	imports: [TodoItemComponent, TodoFormComponent],
	templateUrl: './todos.component.html'
})
export class TodosComponent {
	todos = inject(TODOS);
}

components/todos/todos.component.html#

HTML
<div>
		@if (todos().data.length) {
		<div class="grid grid-cols-[auto,auto,auto,auto] gap-3 justify-items-start">
				@for (todo of todos().data; track todo.id) {
				<app-todo-item class="contents" [todo]="todo" />
				}
		</div>
		} @else if (todos().loading) {
		<p>Loading...</p>
		} @else if (todos().error) {
		<p role="alert" class="text-red-600">{{ todos().error?.message }}</p>
		} @else {
		<p><b>Add your first todo item!</b></p>
		}
		<app-todo-form />
</div>

components/todo-form/todo-form.component.ts#

The form resets to a generated example value after a successful write.

TypeScript
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { USER } from '@lib/auth';
import { addTodo, generateText } from '@lib/todos';

@Component({
	selector: 'app-todo-form',
	standalone: true,
	imports: [FormsModule],
	templateUrl: './todo-form.component.html'
})
export class TodoFormComponent {
	private user = inject(USER);
	text = generateText();
	error = signal<string | null>(null);

	async add() {
		const result = await addTodo(this.text.trim(), this.user().data);
		this.error.set(result.error);
		if (!result.error) {
			this.text = generateText();
		}
	}
}

components/todo-form/todo-form.component.html#

HTML
<form class="mt-5" (ngSubmit)="add()">
		<input class="rounded-lg border p-2" name="task" aria-label="Task" [(ngModel)]="text" />
		<button class="rounded-lg border bg-purple-600 p-2 font-semibold text-white" type="submit"
				[disabled]="!text.trim()">
				Add Task
		</button>
		@if (error(); as message) {
		<p role="alert" class="text-red-600">{{ message }}</p>
		}
</form>

components/todo-item/todo-item.component.ts#

Each item owns its action error so one failed update does not replace the whole list state.

TypeScript
import { CommonModule } from '@angular/common';
import { Component, Input, signal } from '@angular/core';
import { deleteTodo, updateTodo } from '@lib/todos';

@Component({
	selector: 'app-todo-item',
	standalone: true,
	imports: [CommonModule],
	templateUrl: './todo-item.component.html'
})
export class TodoItemComponent {
	@Input() todo!: TodoDoc;

	error = signal<string | null>(null);

	async toggleStatus() {
		this.error.set((await updateTodo(this.todo.id, !this.todo.complete)).error);
	}

	async remove() {
		this.error.set((await deleteTodo(this.todo.id)).error);
	}
}

components/todo-item/todo-item.component.html#

HTML
<span [ngClass]="todo.complete ? 'line-through text-green-600' : ''">
		{{ todo.text }}
</span>
<span [ngClass]="todo.complete ? 'line-through text-green-600' : ''">
		{{ todo.id }}
</span>

<button type="button" [attr.aria-label]="todo.complete ? 'Mark task incomplete' : 'Mark task complete'"
		(click)="toggleStatus()">{{ todo.complete ? '✔️' : '❌' }}</button>
<button type="button" aria-label="Delete task" (click)="remove()">🗑</button>
@if (error(); as message) {
<p role="alert" class="text-red-600">{{ message }}</p>
}

Server-side Firestore#

The About page reads one Firestore document in an Analog server loader.

The server uses the Firestore Lite client to keep the server bundle smaller and validates the document before returning it.

lib/firebase-lite.ts#

This client is server-only; do not import it into browser components.

TypeScript
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore/lite";

const firebase_config = JSON.parse(import.meta.env['VITE_FIREBASE_CONFIG']);

const serverApp = initializeApp(firebase_config);

// !!! This is imported from `firestore/lite` directory for smaller server imports
export const serverDB = getFirestore(serverApp);

lib/about.ts#

Keep Firestore access and document validation together in a small server helper.

TypeScript
import {
	doc,
	getDoc
} from "firebase/firestore/lite";
import { serverDB } from "./firebase-lite";
import * as v from "valibot";
import { createError } from "h3";

// 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"));

	// Turn a missing document into an HTTP 404 response.
	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;
};

pages/about.server.ts#

The page loader stays small and delegates Firebase access to the server helper.

TypeScript
import type { PageServerLoad } from '@analogjs/router';
import { getAbout } from '@lib/about';

export const load = async (_context: PageServerLoad): Promise<AboutDoc> => {
  return await getAbout();
};

components/about/about.component.ts#

injectLoad exposes the server loader result as a signal for the standalone component.

TypeScript
import { Component } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { injectLoad } from '@analogjs/router';
import type { load } from '../../pages/about.server';

@Component({
		selector: 'app-about',
		standalone: true,
		template: `
		@if (about(); as data) {
		<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>
		}
		`
})
export default class AboutComponent {
		about = toSignal(injectLoad<typeof load>(), { requireSync: true });
}

pages/about.page.ts#

TypeScript
import { Component } from '@angular/core';
import AboutComponent from '@components/about/about.component';

@Component({
	selector: 'app-route',
	standalone: true,
	imports: [AboutComponent],
	template: ` <app-about /> `
})
export default class AboutRoute { }

App Structure#

Analog discovers the page files and the root component supplies navigation.

Files ending in .page.ts become routes, while .server.ts files provide server-side page data.

pages/index.page.ts#

TypeScript
import { Component } from '@angular/core';
import { HomeComponent } from '@components/home/home.component';

@Component({
	selector: 'app-index',
	standalone: true,
	imports: [HomeComponent],
	template: ` <app-home /> `
})
export default class IndexComponent { }

app.component.ts#

TypeScript
import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';

@Component({
	selector: 'app-root',
	standalone: true,
	imports: [RouterOutlet, RouterLink],
	template: `
	<router-outlet />
	<nav class="flex gap-3 justify-center mt-5">
			<a routerLink="/">Home</a>
			<a routerLink="/about">About</a>
	</nav>
	`
})
export class AppComponent { }

Build Configuration#

Vite handles browser aliases, and the same alias map is passed to Nitro for server-side modules.

vite.config.ts#

TypeScript
/// <reference types="vitest" />

import { defineConfig } from 'vite';
import analog from '@analogjs/platform';
import { resolve } from 'path';

const aliases = {
	'@lib': resolve(import.meta.dirname, './src/app/lib'),
	'@components': resolve(import.meta.dirname, './src/app/components')
};

export default defineConfig(({ mode }) => ({
	publicDir: 'src/assets',
	build: {
		target: ['es2020'],
	},
	resolve: {
		alias: aliases,
		mainFields: ['module'],
	},
	optimizeDeps: {
		exclude: ['@angular-devkit/core'],
	},
	plugins: [analog({
		nitro: {
			preset: 'netlify-edge',
			alias: aliases
		}
	})],
	test: {
		globals: true,
		environment: 'jsdom',
		setupFiles: ['src/test.ts'],
		include: ['**/*.spec.ts'],
		reporters: ['default'],
	},
	define: {
		'import.meta.vitest': mode !== 'production',
	},
}));

And that's it!

J

Comments

Share a thought or join the conversation.

Sign In to comment or reply.