Angular19 Firebase Guide
Angular19 Firebase Guide
1. Introduction
Welcome to this comprehensive guide on integrating Firebase services with Angular 19
standalone components. This guide is designed for mid-level Angular developers who
want to leverage Firebase's powerful backend services in their applications.
In this guide, we'll build a practical To-Do application that demonstrates how to
integrate and use the following Firebase services:
We'll also cover deployment using GitLab CI/CD pipelines, ensuring you have a complete
end-to-end solution from development to production.
Why Firebase?
By the end of this guide, you'll have a fully functional To-Do application with user
authentication, real-time data synchronization, file storage capabilities, and serverless
backend functions—all deployed through an automated CI/CD pipeline.
2. Prerequisites
Before we begin building our Angular 19 + Firebase To-Do application, let's ensure you
have all the necessary tools and accounts set up.
Required Software
After running these commands, you should have: - [Link] v16.x or higher - npm v8.x or
higher - Angular CLI v16.x or higher (compatible with Angular 19) - Firebase CLI v12.x or
higher
These tools are essential for developing and deploying our Angular application with
Firebase integration.
Account Requirements
1. Firebase Account: You'll need a Google account to access Firebase. If you don't
have a Firebase project yet, we'll create one in the next section.
2. GitLab Account: For CI/CD deployment, you'll need a GitLab account. Make sure
you have access to create repositories and configure CI/CD pipelines.
IDE Recommendations
While you can use any code editor, Visual Studio Code provides excellent support for
Angular and TypeScript development. Consider installing these extensions:
Project Planning
Before diving into code, let's understand what we'll be building:
Now that we have our prerequisites covered, let's move on to setting up our Angular 19
project with standalone components.
3. Project Setup
In this section, we'll set up a new Angular 19 project using standalone components and
prepare it for Firebase integration.
This command creates a new Angular project with: - Standalone components (no
NgModules) - Routing configuration - SCSS for styling
angular-firebase-todo/
├── .angular/
├── .vscode/
├── node_modules/
├── src/
│ ├── app/
│ │ ├── [Link]
│ │ ├── [Link]
│ │ ├── [Link]
│ │ ├── [Link]
│ │ ├── [Link]
│ │ ├── [Link]
│ │ └── ...
│ ├── assets/
│ ├── environments/
│ ├── [Link]
│ ├── [Link]
│ ├── [Link]
│ └── [Link]
├── .editorconfig
├── .gitignore
├── [Link]
├── [Link]
├── [Link]
├── [Link]
├── [Link]
└── [Link]
The key files to note are: - [Link] : Contains application-level providers -
[Link] : Defines the application routes - [Link] : The entry point that
bootstraps the application
// src/environments/[Link]
export const environment = {
production: true,
firebase: {
apiKey: "YOUR_API_KEY",
authDomain: "[Link]",
projectId: "your-project-id",
storageBucket: "[Link]",
messagingSenderId: "YOUR_MESSAGING_SENDER_ID",
appId: "YOUR_APP_ID",
measurementId: "YOUR_MEASUREMENT_ID"
}
};
// src/environments/[Link]
export const environment = {
production: false,
firebase: {
apiKey: "YOUR_API_KEY",
authDomain: "[Link]",
projectId: "your-project-id",
storageBucket: "[Link]",
messagingSenderId: "YOUR_MESSAGING_SENDER_ID",
appId: "YOUR_APP_ID",
measurementId: "YOUR_MEASUREMENT_ID"
}
};
Important security note: Never commit your actual Firebase API keys to public
repositories. We'll address proper handling of these secrets in the GitLab CI/CD section.
For local development, these placeholder values will be replaced with your actual
Firebase configuration in the next section.
This configuration ensures that during development, Angular will use the development
environment file, while in production, it will use the production environment file.
Now our Angular project is set up and ready for Firebase integration, which we'll cover in
the next section.
4. Firebase Project Setup
In this section, we'll create a Firebase project and configure it for use with our Angular
application.
# Login to Firebase
firebase login
When running firebase init , you'll be prompted to: 1. Select Firebase features
(select Firestore, Hosting, Storage, and Functions) 2. Choose an existing project or create
a new one 3. Accept default options for most prompts, but make sure to: - Choose
TypeScript for Cloud Functions - Use ESLint for Cloud Functions - Don't overwrite
existing files when prompted
1. Go to Firebase Console
2. Select your project
3. Navigate to each service and enable it:
4. Authentication: Set up sign-in methods (Email/Password, Google, etc.)
5. Firestore: Create database in production or test mode
6. Storage: Initialize storage with default rules
7. Functions: Will be enabled when you deploy your first function
Getting Firebase Configuration
To connect your Angular app to Firebase, you need the Firebase configuration:
Now, update your environment files with the actual Firebase configuration:
// src/environments/[Link] and
[Link]
export const environment = {
production: true, // or false for development
firebase: {
apiKey: "actual-api-key",
authDomain: "[Link]",
projectId: "your-project-id",
storageBucket: "[Link]",
messagingSenderId: "your-messaging-sender-id",
appId: "your-app-id",
measurementId: "your-measurement-id"
}
};
// src/app/[Link]
import { ApplicationConfig, importProvidersFrom } from
'@angular/core';
import { provideRouter } from '@angular/router';
import { provideAnimations } from '@angular/platform-browser/
animations';
With this setup, your Angular application is now connected to Firebase and ready to use
its services. In the next sections, we'll implement authentication, Firestore database
operations, storage, and cloud functions.
5. Firebase Authentication
In this section, we'll implement user authentication using Firebase Authentication in our
Angular 19 application.
@Injectable({
providedIn: 'root'
})
export class AuthService {
private auth: Auth = inject(Auth);
This service provides: - User registration with email and password - Login with email and
password - Logout functionality - Current user state as an Observable - Error handling for
authentication operations
Note that we're using the functional approach with inject() instead of constructor
injection, which is the recommended pattern for Angular 19 standalone components.
Login Component
// src/app/auth/login/[Link]
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule,
Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { AuthService } from '../[Link]';
@Component({
selector: 'app-login',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './[Link]',
styleUrls: ['./[Link]']
})
export class LoginComponent {
private fb = inject(FormBuilder);
private authService = inject(AuthService);
private router = inject(Router);
onSubmit(): void {
if ([Link]) {
return;
}
[Link] = true;
[Link] = '';
[Link](email, password).subscribe({
next: () => {
[Link] = false;
[Link](['/todos']);
},
error: (error) => {
[Link] = false;
[Link] = [Link];
}
});
}
}
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
formControlName="password"
placeholder="Enter your password"
>
<div class="error"
*ngIf="[Link]('password')?.invalid &&
[Link]('password')?.touched">
<span *ngIf="[Link]('password')?.errors?.
['required']">Password is required</span>
<span *ngIf="[Link]('password')?.errors?.
['minlength']">Password must be at least 6 characters</span>
</div>
</div>
<div class="register-link">
Don't have an account? <a routerLink="/
register">Register</a>
</div>
</form>
</div>
/* src/app/auth/login/[Link] */
.login-container {
max-width: 400px;
margin: 2rem auto;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
background-color: #fff;
h2 {
text-align: center;
margin-bottom: 1.5rem;
color: #333;
}
.form-group {
margin-bottom: 1.5rem;
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
&:focus {
outline: none;
border-color: #4285f4;
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2);
}
}
.error {
color: #d93025;
font-size: 0.875rem;
margin-top: 0.25rem;
}
}
.error-message {
color: #d93025;
margin-bottom: 1rem;
text-align: center;
}
button {
width: 100%;
padding: 0.75rem;
background-color: #4285f4;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: #3367d6;
}
&:disabled {
background-color: #a8c7fa;
cursor: not-allowed;
}
}
.register-link {
text-align: center;
margin-top: 1rem;
a {
color: #4285f4;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
Register Component
// src/app/auth/register/[Link]
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule,
Validators } from '@angular/forms';
import { Router, RouterModule } from '@angular/router';
import { AuthService } from '../[Link]';
@Component({
selector: 'app-register',
standalone: true,
imports: [CommonModule, ReactiveFormsModule, RouterModule],
templateUrl: './[Link]',
styleUrls: ['./[Link]']
})
export class RegisterComponent {
private fb = inject(FormBuilder);
private authService = inject(AuthService);
private router = inject(Router);
passwordMatchValidator(form: FormGroup) {
const password = [Link]('password')?.value;
const confirmPassword = [Link]('confirmPassword')?.value;
onSubmit(): void {
if ([Link]) {
return;
}
[Link] = true;
[Link] = '';
<div class="form-group">
<label for="email">Email</label>
<input
type="email"
id="email"
formControlName="email"
placeholder="Enter your email"
>
<div class="error"
*ngIf="[Link]('email')?.invalid &&
[Link]('email')?.touched">
<span *ngIf="[Link]('email')?.errors?.
['required']">Email is required</span>
<span *ngIf="[Link]('email')?.errors?.
['email']">Please enter a valid email</span>
</div>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
id="password"
formControlName="password"
placeholder="Enter your password"
>
<div class="error"
*ngIf="[Link]('password')?.invalid &&
[Link]('password')?.touched">
<span *ngIf="[Link]('password')?.errors?.
['required']">Password is required</span>
<span *ngIf="[Link]('password')?.errors?.
['minlength']">Password must be at least 6 characters</span>
</div>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input
type="password"
id="confirmPassword"
formControlName="confirmPassword"
placeholder="Confirm your password"
>
<div class="error"
*ngIf="[Link]('confirmPassword')?.invalid &&
[Link]('confirmPassword')?.touched">
<span
*ngIf="[Link]('confirmPassword')?.errors?.
['required']">Confirm Password is required</span>
</div>
<div class="error" *ngIf="[Link]?.
['passwordMismatch'] &&
[Link]('confirmPassword')?.touched">
Passwords do not match
</div>
</div>
/* src/app/auth/register/[Link] */
.register-container {
max-width: 400px;
margin: 2rem auto;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
background-color: #fff;
h2 {
text-align: center;
margin-bottom: 1.5rem;
color: #333;
}
.form-group {
margin-bottom: 1.5rem;
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
&:focus {
outline: none;
border-color: #4285f4;
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2);
}
}
.error {
color: #d93025;
font-size: 0.875rem;
margin-top: 0.25rem;
}
}
.error-message {
color: #d93025;
margin-bottom: 1rem;
text-align: center;
}
button {
width: 100%;
padding: 0.75rem;
background-color: #4285f4;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: #3367d6;
}
&:disabled {
background-color: #a8c7fa;
cursor: not-allowed;
}
}
.login-link {
text-align: center;
margin-top: 1rem;
a {
color: #4285f4;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
// src/app/auth/[Link]
import { inject } from '@angular/core';
import { Router, CanActivateFn } from '@angular/router';
import { map, take } from 'rxjs/operators';
import { AuthService } from './[Link]';
return [Link]$.pipe(
take(1),
map(isLoggedIn => {
if (isLoggedIn) {
return true;
} else {
[Link](['/login'], { queryParams: { returnUrl:
[Link] } });
return false;
}
})
);
};
This guard: - Uses the functional guard approach introduced in Angular 14+ - Checks if
the user is logged in - Redirects to the login page if not authenticated - Preserves the
intended destination URL as a query parameter
Updating Routes
Now, let's update the application routes to include our authentication components and
guards:
// src/app/[Link]
import { Routes } from '@angular/router';
import { LoginComponent } from './auth/login/[Link]';
import { RegisterComponent } from './auth/register/
[Link]';
import { authGuard } from './auth/[Link]';
This routing configuration: - Redirects the root path to the todos page - Provides routes
for login and register - Lazy loads the todo list component - Protects the todos route with
our auth guard - Handles unknown routes with a redirect
// src/app/shared/navigation/[Link]
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { AuthService } from '../../auth/[Link]';
@Component({
selector: 'app-navigation',
standalone: true,
imports: [CommonModule, RouterModule],
templateUrl: './[Link]',
styleUrls: ['./[Link]']
})
export class NavigationComponent {
private authService = inject(AuthService);
user$ = [Link]$;
logout(): void {
[Link]().subscribe();
}
}
<div class="navbar-menu">
<ng-container *ngIf="user$ | async as user; else
notLoggedIn">
<span class="user-greeting">Hello, {{ [Link] ||
'User' }}</span>
<a routerLink="/todos" class="nav-link">My Todos</a>
<button class="logout-button" (click)="logout()">Logout</
button>
</ng-container>
<ng-template #notLoggedIn>
<a routerLink="/login" class="nav-link">Login</a>
<a routerLink="/register" class="nav-link">Register</a>
</ng-template>
</div>
</nav>
/* src/app/shared/navigation/[Link] */
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background-color: #4285f4;
color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.navbar-brand {
a {
color: white;
font-size: 1.25rem;
font-weight: 600;
text-decoration: none;
}
}
.navbar-menu {
display: flex;
align-items: center;
gap: 1.5rem;
.user-greeting {
margin-right: 0.5rem;
}
.nav-link {
color: white;
text-decoration: none;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
.logout-button {
background-color: transparent;
border: 1px solid white;
color: white;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
}
}
.navbar-brand {
margin-bottom: 1rem;
}
.navbar-menu {
width: 100%;
justify-content: center;
}
}
}
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, NavigationComponent],
template: `
<app-navigation></app-navigation>
<main class="container">
<router-outlet></router-outlet>
</main>
`,
styles: [`
.container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
`]
})
export class AppComponent {
title = 'angular-firebase-todo';
}
In the next section, we'll implement Firestore database integration for our To-Do
application.
This model includes: - Basic to-do properties (title, description, completion status) -
Timestamps for creation and updates - Due date for task deadlines - User ID to associate
todos with specific users - Optional attachment fields for file storage integration
// src/app/todos/services/[Link]
import { Injectable, inject } from '@angular/core';
import {
Firestore,
collection,
collectionData,
doc,
addDoc,
updateDoc,
deleteDoc,
query,
where,
orderBy,
serverTimestamp,
DocumentReference,
Timestamp
} from '@angular/fire/firestore';
import { Observable, from, map, switchMap } from 'rxjs';
import { AuthService } from '../../auth/[Link]';
import { Todo } from '../models/[Link]';
@Injectable({
providedIn: 'root'
})
export class TodoService {
private firestore: Firestore = inject(Firestore);
private authService: AuthService = inject(AuthService);
if (!user) {
throw new Error('User must be logged in to add a todo');
}
const newTodo = {
...todo,
userId: [Link],
completed: [Link] || false,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp()
};
// Update a todo
updateTodo(id: string, changes: Partial<Omit<Todo, 'id' |
'createdAt' | 'userId'>>): Observable<void> {
const todoDoc = doc([Link], `todos/${id}`);
const updatedTodo = {
...changes,
updatedAt: serverTimestamp()
};
// Delete a todo
deleteTodo(id: string): Observable<void> {
const todoDoc = doc([Link], `todos/${id}`);
return from(deleteDoc(todoDoc));
}
if ([Link]) {
[Link] = ([Link] as
Timestamp).toDate();
}
return converted;
});
}
}
This service provides: - CRUD operations for to-do items - Filtering for completed and
incomplete todos - User-specific data isolation - Automatic timestamp handling -
Conversion between Firestore Timestamps and JavaScript Dates
// src/app/todos/todo-list/[Link]
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TodoService } from '../services/[Link]';
import { TodoItemComponent } from '../todo-item/todo-
[Link]';
import { TodoFormComponent } from '../todo-form/todo-
[Link]';
import { Todo } from '../models/[Link]';
import { Observable } from 'rxjs';
@Component({
selector: 'app-todo-list',
standalone: true,
imports: [CommonModule, TodoItemComponent, TodoFormComponent],
templateUrl: './[Link]',
styleUrls: ['./[Link]']
})
export class TodoListComponent implements OnInit {
private todoService = inject(TodoService);
todos$!: Observable<Todo[]>;
activeFilter: 'all' | 'active' | 'completed' = 'all';
ngOnInit(): void {
[Link]('all');
}
switch (filter) {
case 'all':
[Link]$ = [Link]();
break;
case 'active':
[Link]$ = [Link]();
break;
case 'completed':
[Link]$ = [Link]();
break;
}
}
onTodoAdded(): void {
// Refresh the list when a new todo is added
[Link]([Link]);
}
<div class="filters">
<button
[[Link]]="activeFilter === 'all'"
(click)="filterTodos('all')"
>
All
</button>
<button
[[Link]]="activeFilter === 'active'"
(click)="filterTodos('active')"
>
Active
</button>
<button
[[Link]]="activeFilter === 'completed'"
(click)="filterTodos('completed')"
>
Completed
</button>
</div>
<app-todo-form (todoAdded)="onTodoAdded()"></app-todo-form>
<div class="todo-list">
<ng-container *ngIf="todos$ | async as todos">
<div *ngIf="[Link] === 0" class="empty-state">
No todos found. Add a new one above!
</div>
<app-todo-item
*ngFor="let todo of todos"
[todo]="todo"
(toggleComplete)="onTodoToggled($event)"
(deleteTodo)="onTodoDeleted($event)"
></app-todo-item>
</ng-container>
</div>
</div>
/* src/app/todos/todo-list/[Link] */
.todo-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
h1 {
text-align: center;
margin-bottom: 2rem;
color: #333;
}
.filters {
display: flex;
justify-content: center;
margin-bottom: 2rem;
gap: 0.5rem;
button {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
background-color: white;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
&:hover {
background-color: #f5f5f5;
}
&.active {
background-color: #4285f4;
color: white;
border-color: #4285f4;
}
}
}
.todo-list {
margin-top: 2rem;
.empty-state {
text-align: center;
padding: 2rem;
background-color: #f9f9f9;
border-radius: 8px;
color: #666;
}
}
}
.filters {
flex-wrap: wrap;
button {
flex: 1;
min-width: 80px;
}
}
}
}
// src/app/todos/todo-item/[Link]
import { Component, EventEmitter, Input, Output } from
'@angular/core';
import { CommonModule } from '@angular/common';
import { Todo } from '../models/[Link]';
@Component({
selector: 'app-todo-item',
standalone: true,
imports: [CommonModule],
templateUrl: './[Link]',
styleUrls: ['./[Link]']
})
export class TodoItemComponent {
@Input() todo!: Todo;
@Output() toggleComplete = new EventEmitter<Todo>();
@Output() deleteTodo = new EventEmitter<string>();
onToggleComplete(): void {
[Link]([Link]);
}
onDelete(): void {
[Link]([Link]);
}
<div class="todo-content">
<h3 class="todo-title">{{ [Link] }}</h3>
<div class="todo-meta">
<span *ngIf="[Link]" class="todo-due-date">
Due: {{ formatDate([Link]) }}
</span>
/* src/app/todos/todo-item/[Link] */
.todo-item {
display: flex;
align-items: flex-start;
padding: 1rem;
margin-bottom: 1rem;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: all 0.2s;
&.completed {
opacity: 0.7;
.todo-title {
text-decoration: line-through;
color: #888;
}
}
.todo-checkbox {
margin-right: 1rem;
margin-top: 0.25rem;
input[type="checkbox"] {
display: none;
& + label {
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid #4285f4;
border-radius: 4px;
cursor: pointer;
position: relative;
&:after {
content: '';
position: absolute;
display: none;
left: 6px;
top: 2px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
}
&:checked + label {
background-color: #4285f4;
&:after {
display: block;
}
}
}
}
.todo-content {
flex: 1;
.todo-title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
color: #333;
}
.todo-description {
margin: 0 0 0.75rem;
color: #666;
font-size: 0.9rem;
}
.todo-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.8rem;
color: #888;
.todo-due-date {
display: flex;
align-items: center;
&:before {
content: '⏰';
margin-right: 0.25rem;
}
}
.todo-attachment {
display: flex;
align-items: center;
&:before {
content: '📎';
margin-right: 0.25rem;
}
a {
color: #4285f4;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
}
.delete-button {
background: none;
border: none;
color: #d93025;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
margin-left: 0.5rem;
line-height: 1;
opacity: 0.5;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
}
.todo-checkbox {
margin-bottom: 0.5rem;
}
.delete-button {
align-self: flex-end;
margin-top: 0.5rem;
}
}
}
// src/app/todos/todo-form/[Link]
import { Component, EventEmitter, Output, inject } from
'@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule,
Validators } from '@angular/forms';
import { TodoService } from '../services/[Link]';
@Component({
selector: 'app-todo-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './[Link]',
styleUrls: ['./[Link]']
})
export class TodoFormComponent {
private fb = inject(FormBuilder);
private todoService = inject(TodoService);
showFullForm = false;
toggleFullForm(): void {
[Link] = ![Link];
}
onSubmit(): void {
if ([Link]) {
return;
}
<div class="form-actions">
<button
type="button"
class="cancel-button"
(click)="toggleFullForm()"
>
Cancel
</button>
<button
type="submit"
class="submit-button"
[disabled]="[Link]"
>
Add Todo
</button>
</div>
</div>
</form>
</div>
/* src/app/todos/todo-form/[Link] */
.todo-form-container {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
.form-header {
display: flex;
align-items: center;
padding: 1rem;
cursor: pointer;
.title-input {
flex: 1;
border: none;
font-size: 1rem;
padding: 0.5rem;
background-color: transparent;
&:focus {
outline: none;
}
}
.expand-button {
background-color: #4285f4;
color: white;
border: none;
width: 28px;
height: 28px;
border-radius: 50%;
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s;
&.expanded {
transform: rotate(180deg);
}
}
}
.form-details {
padding: 0 1rem 1rem;
border-top: 1px solid #eee;
.form-group {
margin-bottom: 1rem;
label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: #555;
}
textarea, input[type="date"] {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.875rem;
&:focus {
outline: none;
border-color: #4285f4;
box-shadow: 0 0 0 2px rgba(66, 133, 244, 0.2);
}
}
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
button {
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
cursor: pointer;
transition: background-color 0.2s;
}
.cancel-button {
background-color: transparent;
border: 1px solid #ddd;
color: #555;
&:hover {
background-color: #f5f5f5;
}
}
.submit-button {
background-color: #4285f4;
border: none;
color: white;
&:hover {
background-color: #3367d6;
}
&:disabled {
background-color: #a8c7fa;
cursor: not-allowed;
}
}
}
}
}
// Default deny
match /{document=**} {
allow read, write: if false;
}
}
}
EOF
These security rules ensure: - Only authenticated users can create todos - Users can only
create todos with their own user ID - Users can only read, update, or delete their own
todos - All other access is denied by default
// src/app/[Link]
import { ApplicationConfig, importProvidersFrom } from
'@angular/core';
import { provideRouter } from '@angular/router';
import { provideAnimations } from '@angular/platform-browser/
animations';
This configuration enables offline persistence, allowing users to: - View and interact with
their todos even when offline - Create and modify todos while offline - Have changes
automatically synchronized when back online
In the next section, we'll implement Firebase Storage for file attachments.
// Default deny
match /{allPaths=**} {
allow read, write: if false;
}
}
}
EOF
These security rules ensure: - Files are organized by user ID to prevent unauthorized
access - Users can only read and write their own files - All other access is denied by
default
Creating a Storage Service
Now, let's create a service to handle file uploads and downloads:
// src/app/shared/services/[Link]
import { Injectable, inject } from '@angular/core';
import {
Storage,
ref,
uploadBytesResumable,
getDownloadURL,
deleteObject
} from '@angular/fire/storage';
import { Observable, from, switchMap, of } from 'rxjs';
import { AuthService } from '../../auth/[Link]';
@Injectable({
providedIn: 'root'
})
export class StorageService {
private storage: Storage = inject(Storage);
private authService: AuthService = inject(AuthService);
if (!user) {
throw new Error('User must be logged in to upload files');
}
getDownloadURL([Link]).then(downloadUrl => {
[Link]({
progress: 100,
downloadUrl,
fileName: [Link],
filePath
});
[Link]();
}).catch(error => {
[Link]('Error getting download URL:', error);
[Link](error);
});
}
);
This service provides: - File upload functionality with progress tracking - Download URL
generation for uploaded files - File deletion capability - User-specific file paths for
security
// Update src/app/todos/models/[Link]
export interface Todo {
id?: string;
title: string;
description?: string;
completed: boolean;
createdAt: Date;
updatedAt: Date;
dueDate?: Date;
userId: string;
attachmentUrl?: string;
attachmentName?: string;
attachmentPath?: string; // Add this field to store the file
path
}
// Update src/app/todos/services/[Link]
// Add this method to the TodoService class
if (!user) {
throw new Error('User must be logged in to add a todo');
}
const newTodo = {
...todo,
userId: [Link],
completed: [Link] || false,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
attachmentUrl,
attachmentName,
attachmentPath
};
// Update src/app/todos/todo-form/[Link]
import { Component, EventEmitter, Output, inject } from
'@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, ReactiveFormsModule,
Validators } from '@angular/forms';
import { TodoService } from '../services/[Link]';
import { StorageService, UploadTask } from '../../shared/
services/[Link]';
import { switchMap } from 'rxjs';
@Component({
selector: 'app-todo-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './[Link]',
styleUrls: ['./[Link]']
})
export class TodoFormComponent {
private fb = inject(FormBuilder);
private todoService = inject(TodoService);
private storageService = inject(StorageService);
@Output() todoAdded = new EventEmitter<void>();
showFullForm = false;
toggleFullForm(): void {
[Link] = ![Link];
}
onSubmit(): void {
if ([Link]) {
return;
}
const todo = {
title: [Link],
description: [Link],
dueDate: dueDate,
completed: false
};
[Link]([Link]).subscribe({
next: (task: UploadTask) => {
[Link] = [Link];
<div class="form-group">
<label for="dueDate">Due Date</label>
<input
type="date"
id="dueDate"
formControlName="dueDate"
>
</div>
<div class="form-group">
<label for="attachment">Attachment</label>
<div class="file-input-container">
<input
type="file"
id="attachment"
(change)="onFileSelected($event)"
[disabled]="isUploading"
>
<div class="selected-file" *ngIf="selectedFile">
Selected: {{ [Link] }}
</div>
</div>
</div>
<div class="form-actions">
<button
type="button"
class="cancel-button"
(click)="toggleFullForm()"
[disabled]="isUploading"
>
Cancel
</button>
<button
type="submit"
class="submit-button"
[disabled]="[Link] || isUploading"
>
{{ isUploading ? 'Uploading...' : 'Add Todo' }}
</button>
</div>
</div>
</form>
</div>
/* Update src/app/todos/todo-form/[Link] */
/* Add these styles to the existing SCSS file */
.file-input-container {
margin-top: 0.5rem;
input[type="file"] {
width: 100%;
padding: 0.5rem 0;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.selected-file {
margin-top: 0.5rem;
font-size: 0.875rem;
color: #555;
}
}
.upload-progress {
margin: 1rem 0;
.progress-bar {
height: 8px;
background-color: #f0f0f0;
border-radius: 4px;
overflow: hidden;
.progress-fill {
height: 100%;
background-color: #4285f4;
transition: width 0.3s ease;
}
}
.progress-text {
text-align: right;
font-size: 0.75rem;
color: #555;
margin-top: 0.25rem;
}
}
// Update src/app/todos/todo-item/[Link]
// Add this method to the TodoItemComponent class
downloadAttachment(): void {
if ([Link]) {
[Link]([Link], '_blank');
}
}
/* Update src/app/todos/todo-item/[Link] */
/* Update the todo-attachment styles */
.todo-attachment {
display: flex;
align-items: center;
&:before {
content: '📎';
margin-right: 0.25rem;
}
.attachment-link {
color: #4285f4;
text-decoration: none;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
// Update src/app/todos/todo-list/[Link]
// Update the onTodoDeleted method
This command uploads your security rules to Firebase, ensuring they're applied to your
Storage bucket.
With these components and services in place, we have implemented Firebase Storage
integration for our To-Do application. Users can now attach files to their to-do items,
with all files securely stored in Firebase Storage and protected by appropriate security
rules.
In the next section, we'll implement Firebase Cloud Functions for background
processing.
# Install dependencies
npm install cors express moment nodemailer
# Return to the project root
cd ..
// functions/src/[Link]
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
import * as express from 'express';
import * as cors from 'cors';
import * as moment from 'moment';
import * as nodemailer from 'nodemailer';
// Initialize Firestore
const db = [Link]();
// Verify authentication
const authHeader = [Link];
if (!authHeader || ) {
return [Link](403).json({ error: 'Unauthorized' });
}
return [Link](200).json(todos);
} catch (error) {
[Link]('Error getting todos:', error);
return [Link](500).json({ error: 'Internal server
error' });
}
});
[Link](doc => {
const todo = {
id: [Link],
...[Link]()
};
if (!userTodos[[Link]]) {
userTodos[[Link]] = [];
}
userTodos[[Link]].push(todo);
});
if (!email) {
[Link](`No email found for user ${userId}`);
return;
}
[Link](todo => {
const dueDate =
moment([Link]()).format('MMM DD, YYYY');
todoList += `- ${[Link]} (Due: ${dueDate})\n`;
});
const emailContent = `
Hello ${[Link] || 'there'},
${todoList}
Best regards,
Todo App Team
`;
await [Link](emailPromises);
return null;
} catch (error) {
[Link]('Error sending reminders:', error);
return null;
}
});
return null;
} catch (error) {
[Link]('Error cleaning up attachment:', error);
return null;
}
});
if (todoData) {
userId = [Link];
} else if (previousTodoData) {
userId = [Link];
}
if (!userId) {
[Link]('No user ID found');
return null;
}
// Calculate statistics
let totalTodos = 0;
let completedTodos = 0;
[Link](doc => {
totalTodos++;
if ([Link]().completed) {
completedTodos++;
}
});
// src/app/shared/services/[Link]
import { Injectable, inject } from '@angular/core';
import { Functions, httpsCallable } from '@angular/fire/
functions';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, from, switchMap } from 'rxjs';
import { AuthService } from '../../auth/[Link]';
import { Todo } from '../../todos/models/[Link]';
@Injectable({
providedIn: 'root'
})
export class FunctionsService {
private functions: Functions = inject(Functions);
private http: HttpClient = inject(HttpClient);
private authService: AuthService = inject(AuthService);
return from([Link]()).pipe(
switchMap(token => {
const headers = new HttpHeaders({
'Authorization': `Bearer ${token}`
});
return [Link]<Todo[]>(
`${[Link]}/todos/${[Link]}`,
{ headers }
);
})
);
})
);
}
try {
// Get user statistics from Firestore
const statsDoc = await
[Link]('userStats').doc(userId).get();
if (![Link]) {
// Return default stats if none exist
return {
totalTodos: 0,
completedTodos: 0,
completionRate: 0,
lastUpdated: null
};
}
return [Link]();
} catch (error) {
[Link]('Error getting user stats:', error);
throw new [Link](
'internal',
'An error occurred while retrieving user statistics.'
);
}
});
// src/app/shared/user-stats/[Link]
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FunctionsService, UserStats } from '../services/
[Link]';
import { Observable } from 'rxjs';
@Component({
selector: 'app-user-stats',
standalone: true,
imports: [CommonModule],
templateUrl: './[Link]',
styleUrls: ['./[Link]']
})
export class UserStatsComponent implements OnInit {
private functionsService = inject(FunctionsService);
stats$!: Observable<UserStats>;
ngOnInit(): void {
[Link]$ = [Link]();
}
<div class="stat-item">
<div class="stat-value">{{ [Link] }}</div>
<div class="stat-label">Completed</div>
</div>
<div class="stat-item">
<div class="stat-
value">{{ [Link](0) }}%</div>
<div class="stat-label">Completion Rate</div>
</div>
</div>
<div class="stats-footer">
Last updated: {{ formatDate([Link]) }}
</div>
</ng-container>
<ng-template #loading>
<div class="loading-stats">
Loading statistics...
</div>
</ng-template>
</div>
/* src/app/shared/user-stats/[Link] */
.stats-container {
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
padding: 1.5rem;
margin-bottom: 2rem;
h3 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #333;
font-size: 1.25rem;
text-align: center;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
.stat-item {
text-align: center;
.stat-value {
font-size: 2rem;
font-weight: 600;
color: #4285f4;
}
.stat-label {
font-size: 0.875rem;
color: #666;
margin-top: 0.5rem;
}
}
}
.stats-footer {
margin-top: 1.5rem;
text-align: center;
font-size: 0.75rem;
color: #888;
}
.loading-stats {
text-align: center;
padding: 2rem 0;
color: #666;
}
}
// Update src/app/todos/todo-list/[Link]
// Add UserStatsComponent to imports
import { UserStatsComponent } from '../../shared/user-stats/
[Link]';
@Component({
selector: 'app-todo-list',
standalone: true,
imports: [CommonModule, TodoItemComponent, TodoFormComponent,
UserStatsComponent],
templateUrl: './[Link]',
styleUrls: ['./[Link]']
})
<!-- Update src/app/todos/todo-list/[Link] -->
<!-- Add this before the todo-list div -->
<app-user-stats></app-user-stats>
This command uploads your Cloud Functions to Firebase, making them available for
use.
6. Secrets Management: Store sensitive information like API keys and passwords in
environment variables:
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
[Link]
[Link]
# Miscellaneous
/.angular/cache
.sass-cache/
/[Link]
/coverage
/[Link]
[Link]
/typings
# System files
.DS_Store
[Link]
# Firebase
.firebase/
[Link]
[Link]
.[Link]
# Environment files
.env
/src/environments/*.ts
EOF
# Commit changes
git commit -m "Initial commit"
# Add GitLab remote (replace with your GitLab repository URL)
git remote add origin [Link]
[Link]
# Push to GitLab
git push -u origin master
stages:
- setup
- test
- build
- deploy
# Install dependencies
setup:
stage: setup
script:
- npm ci --cache .npm --prefer-offline
artifacts:
paths:
- node_modules/
# Run tests
test:
stage: test
script:
- npm run test:ci
dependencies:
- setup
# Deploy to Firebase
deploy:
stage: deploy
image: node:20-alpine
script:
# Install Firebase CLI
- npm install -g firebase-tools
# Create .firebaserc file
- |
cat > .firebaserc << EOL
{
"projects": {
"default": "${FIREBASE_PROJECT_ID}"
}
}
EOL
# Deploy to Firebase
- firebase deploy --only hosting --token "$(cat ./firebase-
token)" --non-interactive
dependencies:
- build
only:
- master # Only deploy from the master branch
EOF
This CI/CD configuration: - Uses a [Link] Alpine image for faster builds - Caches
node_modules for improved performance - Sets up a pipeline with setup, test, build, and
deploy stages - Creates environment files from GitLab CI/CD variables - Builds the
Angular application - Deploys to Firebase Hosting
FIREBASE_API_KEY
FIREBASE_AUTH_DOMAIN
FIREBASE_PROJECT_ID
FIREBASE_STORAGE_BUCKET
FIREBASE_MESSAGING_SENDER_ID
FIREBASE_APP_ID
FIREBASE_MEASUREMENT_ID
FIREBASE_TOKEN
# Login to Firebase
firebase login:ci
This command will open a browser window for authentication and then display a token.
Copy this token and add it as the FIREBASE_TOKEN variable in GitLab.
# Update [Link]
cat > [Link] << 'EOF'
{
"firestore": {
"rules": "[Link]",
"indexes": "[Link]"
},
"functions": {
"predeploy": [
"npm --prefix \"$RESOURCE_DIR\" run lint",
"npm --prefix \"$RESOURCE_DIR\" run build"
],
"source": "functions"
},
"hosting": {
"public": "dist/angular-firebase-todo/browser",
"ignore": [
"[Link]",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"destination": "/[Link]"
}
]
},
"storage": {
"rules": "[Link]"
},
"emulators": {
"auth": {
"port": 9099
},
"functions": {
"port": 5001
},
"firestore": {
"port": 8080
},
"hosting": {
"port": 5000
},
"storage": {
"port": 9199
},
"ui": {
"enabled": true
}
}
}
EOF
Make sure the public path matches your Angular output directory. For Angular 19, it's
typically dist/angular-firebase-todo/browser .
1. Go to your GitLab project and navigate to CI/CD > Pipelines to monitor the pipeline
execution.
# Deploy to Firebase
firebase deploy
Rollback Deployments
If you need to rollback to a previous deployment:
Replace <SOURCE_SITE_ID> with your Firebase project ID, <VERSION> with the
version to rollback to, and <DESTINATION_SITE_ID> with your Firebase project ID
again.
Monitoring Deployments
You can monitor your deployments in the Firebase console:
1. Go to Firebase Console
2. Select your project
3. Navigate to Hosting
4. View deployment history and status
With this GitLab CI/CD configuration, you have automated the deployment process for
your Angular 19 + Firebase application. The pipeline will build and deploy your
application whenever changes are pushed to the master branch, ensuring a smooth and
consistent deployment process.
In the next section, we'll cover security best practices for your Angular 19 + Firebase
application.
// src/app/auth/[Link]
// Add these methods to your AuthService
// Email verification
sendEmailVerification(user: User): Observable<void> {
return from(sendEmailVerification(user)).pipe(
catchError(error => {
[Link]('Email verification error:', error);
return throwError(() => new Error(`Email verification
failed: ${[Link]}`));
})
);
}
// Account deletion
deleteAccount(): Observable<void> {
const user = [Link];
if (!user) {
return throwError(() => new Error('No user is currently
logged in'));
}
return from(deleteUser(user)).pipe(
catchError(error => {
[Link]('Account deletion error:', error);
return throwError(() => new Error(`Account deletion
failed: ${[Link]}`));
})
);
}
These methods provide: - Password reset functionality for users who forget their
passwords - Email verification to confirm user identities - Account deletion for users who
want to remove their data
// src/app/auth/[Link]
// Add this to your AuthService
switch (persistenceType) {
case 'local':
persistence = browserLocalPersistence;
break;
case 'session':
persistence = browserSessionPersistence;
break;
case 'none':
persistence = inMemoryPersistence;
break;
default:
persistence = browserLocalPersistence;
}
import {
// ... existing imports
sendPasswordResetEmail,
sendEmailVerification,
deleteUser,
setPersistence,
browserLocalPersistence,
browserSessionPersistence,
inMemoryPersistence
} from '@angular/fire/auth';
This allows you to control how long the user stays logged in: - local : User remains
logged in even after closing the browser - session : User remains logged in until the
browser is closed - none : User is logged out when the page is refreshed
// src/app/auth/[Link]
// Add these methods to your AuthService
return from([Link](multiFactorAssertion,
phoneNumber)).pipe(
catchError(error => {
[Link]('MFA enrollment error:', error);
return throwError(() => new Error(`MFA enrollment failed:
${[Link]}`));
})
);
}
import {
// ... existing imports
multiFactor,
MultiFactorSession,
PhoneAuthProvider,
PhoneMultiFactorGenerator
} from '@angular/fire/auth';
Firestore Security Rules Best Practices
// [Link]
rules_version = '2';
service [Link] {
match /databases/{database}/documents {
// Helper function to get user data
function getUserData() {
return get(/databases/$(database)/documents/users/$
([Link])).data
}
// Users collection
match /users/{userId} {
// Users can read and update their own data
allow read, update: if [Link] != null &&
[Link] == userId;
// Only admins can create or delete users
allow create, delete: if [Link] != null &&
isAdmin();
}
// Todos collection
match /todos/{todoId} {
// Users can read, create, update, and delete their own
todos
allow read, create, update, delete: if [Link] !=
null &&
[Link] ==
[Link];
// Admins can read all todos
allow read: if [Link] != null && isAdmin();
}
// Default deny
match /{document=**} {
allow read, write: if false;
}
}
}
These rules implement: - Role-based access control with admin privileges - User-specific
data isolation - Protection of sensitive collections - Default deny for all other access
// [Link]
// Add these validation functions
This validation ensures: - Only allowed fields are present - Required fields are not null -
Field types are correct - String lengths are within acceptable ranges - User can only
create todos with their own user ID
Firebase Storage Security Rules Best Practices
Update your Storage security rules to restrict file sizes and types:
// [Link]
rules_version = '2';
service [Link] {
match /b/{bucket}/o {
// Helper function to check file size
function isValidFileSize() {
return [Link] <= 5 * 1024 * 1024; // 5MB
limit
}
// Default deny
match /{allPaths=**} {
allow read, write: if false;
}
}
}
These rules implement: - File size restrictions (5MB limit) - File type restrictions (images,
PDFs, and text files only) - User-specific file access
API Key and Secret Management
As we've already set up in the GitLab CI/CD section, use environment variables for
sensitive information:
# .[Link]
build:
script:
# Create [Link] file from CI/CD variables
- |
cat > src/environments/[Link] << EOL
export const environment = {
production: true,
firebase: {
apiKey: "${FIREBASE_API_KEY}",
authDomain: "${FIREBASE_AUTH_DOMAIN}",
// ... other Firebase config
}
};
EOL
2. Runtime Configuration
For even better security, consider using runtime configuration instead of build-time
environment files:
// src/app/config/[Link]
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class AppConfigService {
private config: AppConfig | null = null;
loadConfig(): Observable<AppConfig> {
if ([Link]) {
return of([Link]);
}
return [Link]<AppConfig>('/assets/[Link]').pipe(
tap(config => {
[Link] = config;
}),
catchError(error => {
[Link]('Could not load configuration', error);
throw error;
})
);
}
getConfig(): AppConfig {
if (![Link]) {
throw new Error('Config not loaded');
}
return [Link];
}
}
// src/app/[Link]
import { APP_INITIALIZER, ApplicationConfig,
importProvidersFrom } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideAnimations } from '@angular/platform-browser/
animations';
import { provideHttpClient } from '@angular/common/http';
This approach: - Loads configuration at runtime instead of build time - Allows you to
change configuration without rebuilding the application - Keeps sensitive information
out of your source code
Angular provides built-in protection against XSS attacks through its template syntax and
sanitization services. However, you should be careful when using certain APIs:
// src/app/shared/[Link]
import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-
browser';
@Pipe({
name: 'safeHtml',
standalone: true
})
export class SafeHtmlPipe implements PipeTransform {
constructor(private sanitizer: DomSanitizer) {}
Use this pipe only when you absolutely need to render HTML from a trusted source:
This CSP: - Restricts script execution to the same origin - Allows connections to Firebase
services - Restricts image sources - Allows inline styles (needed for Angular) - Restricts
font sources
Cross-Site Request Forgery (CSRF) Protection
Firebase Authentication uses tokens for authentication, which provides inherent
protection against CSRF attacks. However, for your own API endpoints, you should
implement CSRF protection:
// functions/src/[Link]
// Add CSRF protection to your API
next();
};
// functions/src/[Link]
// Add rate limiting middleware
// Current time
const now = [Link]();
This middleware: - Tracks requests by IP address - Limits users to 100 requests per
minute - Resets the count after 1 minute - Returns a 429 status code when the limit is
exceeded
1. Dependency Scanning: Use tools like npm audit to check for vulnerabilities in your
dependencies:
# Fix vulnerabilities
npm audit fix
1. Code Scanning: Use tools like ESLint with security plugins to scan your code for
security issues:
1. Firebase Security Rules Testing: Test your Firestore and Storage security rules:
// [Link]
const firebase = require('@firebase/rules-unit-testing');
const fs = require('fs');
beforeEach(async () => {
await [Link]({ projectId });
});
afterAll(async () => {
await [Link]([Link]().map(app => [Link]()));
});
By implementing these security best practices, you'll ensure your Angular 19 + Firebase
application is protected against common security threats and vulnerabilities. Remember
to regularly update your dependencies, audit your code, and test your security rules to
maintain a high level of security.
Next Steps
To further enhance your Angular 19 + Firebase application, consider exploring these
advanced topics:
• Angular Signals: Implement the new signals API for more efficient state
management
• Angular Server-Side Rendering (SSR): Improve SEO and initial load performance
• Angular PWA: Convert your application to a Progressive Web App for offline
capabilities
• Angular Internationalization (i18n): Add multi-language support
3. Testing
• Unit Testing: Implement comprehensive unit tests with Jasmine and Karma
• End-to-End Testing: Add end-to-end tests with Cypress or Playwright
• Firebase Emulator Suite: Use local emulators for testing Firebase services
4. Performance Optimization
• Angular Documentation
• Firebase Documentation
• Angular Fire Documentation
• GitLab CI/CD Documentation
Happy coding!