Read More on Telerik Blogs
February 17, 2026 Web, Angular
Get A Free Trial

Streamline your Angular setup with this build! Using Angular 20+, signals, AnalogJS, pure Firebase, Firestore Lite and clean injection tokens, we can get rolling with ease.

Angular has changed a lot in the last few years. We now are at Version 20. We can simplify the Angular setup with Firebase, and only add the minimum necessary packages and server infrastructure to get our app working in any environment.

TL;DR

This app setup takes the best of Firebase setups to cover all your bases. You can fetch data purely on the server for good SEO, validate the schema meta data and have a safe “login wall” to prevent unauthorized user access on the client. No need for cookies, sessions or authorization on the server.

Remove ZoneJS

We do NOT need ZoneJS anymore. Signals make Angular faster and more responsive, and remove unnecessary bloat.

AnalogJS

Generate a new Analog component, or use an existing one.

app.config.ts

import {
  provideHttpClient,
  withFetch,
  withInterceptors,
} from '@angular/common/http';
import {
  ApplicationConfig,
  provideBrowserGlobalErrorListeners,
  provideZonelessChangeDetection
} from '@angular/core';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { provideFileRouter, requestContextInterceptor } from '@analogjs/router';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideZonelessChangeDetection(), // <----- change
    provideFileRouter(),
    provideHttpClient(
      withFetch(),
      withInterceptors([requestContextInterceptor])
    ),
    provideClientHydration(withEventReplay()),
  ],
};

Here we change out the Zone provider for a Zoneless one.

npm uninstall zone.js

Also, remove any imports in the main.server.ts and in main.ts.

// Remove these lines
import 'zone.js/node';
import 'zone.js';

vite.config.ts

I also added an alias for @lib, services and @components. I changed the default public prefix for .env info from VITE_ to PUBLIC_, but this is optional. Feel free to change the preset to where you want to deploy.

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

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

// https://vitejs.dev/config/
export default defineConfig(() => ({
  build: {
    target: ['es2020'],
  },
  envPrefix: ['PUBLIC_'],
  resolve: {
    alias: {
      '@services': resolve(__dirname, './src/app/services'),
      '@components': resolve(__dirname, './src/app/components'),
      '@lib': resolve(__dirname, './src/app/lib')
    },
    mainFields: ['module']
  },
  plugins: [
    analog({
      ssr: true,
      static: false,
      nitro: {
        alias: {
          '@lib': resolve(__dirname, './src/app/lib'),
          '@services': resolve(__dirname, './src/app/services'),
          '@components': resolve(__dirname, './src/app/components'),
        },
        preset: 'vercel-edge'
      },
      prerender: {
        routes: [],
      },
    }),
    tailwindcss()
  ],
}));

Utility Functions

For many apps I use in Angular, I have created a few reusable utility functions.

import { TransferState, inject, makeStateKey } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { map } from "rxjs";

export const useAsyncTransferState = async <T>(
    name: string,
    fn: () => T
) => {
    const state = inject(TransferState);
    const key = makeStateKey<T>(name);
    const cache = state.get(key, null);
    if (cache) {
        return cache;
    }
    const data = await fn() as T;
    state.set(key, data);
    return data;
};

export const useTransferState = <T>(
    name: string,
    fn: () => T
) => {
    const state = inject(TransferState);
    const key = makeStateKey<T>(name);
    const cache = state.get(key, null);
    if (cache) {
        return cache;
    }
    const data = fn() as T;
    state.set(key, data);
    return data;
};

export const injectResolver = <T>(name: string) =>
    inject(ActivatedRoute).data.pipe<T>(map(r => r[name]));

export const injectSnapResolver = <T>(name: string) =>
    inject(ActivatedRoute).snapshot.data[name] as T;
  • useAsyncTransferState – This allows the data to be fetched on the server once and hydrated to the browser. By default, the server would fetch the data, then the client would refetch the data. We don’t want extraneous reads in Firestore.
  • injectResolver and injectSnapResolver are helpers to add the resolver data to your component.

schema.service.ts

By default, you can inject Meta to add meta data. However, there is no default schema tool. I created a basic one.

import { Injectable, Inject } from '@angular/core';
import { DOCUMENT } from '@angular/common';

@Injectable({ providedIn: 'root' })
export class Schema {
    constructor(@Inject(DOCUMENT) private document: Document) { }

    /**
     * Adds or updates a JSON-LD script tag in <head>, similar to Title/Meta.
     */
    setSchema(data: Record<string, unknown>, id = 'jsonld-schema'): void {
        const json = JSON.stringify(data).replace(/</g, '\\u003c');

        // Avoid duplicates
        let script = this.document.head.querySelector<HTMLScriptElement>(
            `script#${id}[type="application/ld+json"]`
        );

        if (!script) {
            script = this.document.createElement('script');
            script.id = id;
            script.type = 'application/ld+json';
            this.document.head.appendChild(script);
        }

        script.textContent = json;
    }

    /** Optional cleanup */
    removeSchema(id = 'jsonld-schema'): void {
        const existing = this.document.getElementById(id);
        if (existing) {
            this.document.head.removeChild(existing);
        }
    }
}

Firebase

Next, install pure Firebase. DO NOT use @angular/fire, as it is built with ZoneJS in mind.

npm i firebase

ENV

Set up your .env file to have your firebase public API key available.

PUBLIC_FIREBASE_CONFIG={"apiKey":...."authDomain"...,...}

Next, create the Firebase Service file which will have your firebase sharing injection tokens.

import { isPlatformBrowser } from "@angular/common";
import { inject, InjectionToken, PLATFORM_ID } from "@angular/core";
import { FirebaseApp, getApp, getApps, initializeApp } from "firebase/app";
import { Auth, getAuth } from "firebase/auth";
import { doc, Firestore, getDoc, getFirestore } from "firebase/firestore";
import {
    getFirestore as getFirestoreLite,
    getDoc as getDocLite,
    doc as docLite
} from 'firebase/firestore/lite'

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

export const FIREBASE_APP = new InjectionToken<FirebaseApp>(
    'firebase-app',
    {
        providedIn: 'root',
        factory() {
            return getApps().length
                ? getApp()
                : initializeApp(firebase_config);

        }
    }
);

export const FIREBASE_AUTH = new InjectionToken<Auth | null>(
    'firebase-auth',
    {
        providedIn: 'root',
        factory() {
            const platformID = inject(PLATFORM_ID);
            if (isPlatformBrowser(platformID)) {
                const app = inject(FIREBASE_APP);
                return getAuth(app);
            }
            return null;
        }
    }
);

export const FIREBASE_FIRESTORE = new InjectionToken<Firestore>(
    'firebase-firestore',
    {
        providedIn: 'root',
        factory() {
            const platformID = inject(PLATFORM_ID);
            const app = inject(FIREBASE_APP);
            if (isPlatformBrowser(platformID)) {
                return getFirestore(app);
            }
            return getFirestoreLite(app);
        }
    }
);

export const FIRESTORE_GET_DOC = new InjectionToken(
    'firestore-get-doc',
    {
        providedIn: 'root',
        factory() {
            const db = inject(FIREBASE_FIRESTORE);
            const platformID = inject(PLATFORM_ID);
            return async <T>(path: string) => {

                try {

                    const snap = isPlatformBrowser(platformID)
                        ? await getDoc(doc(db, path))
                        : await getDocLite(docLite(db, path));
                    if (!snap.exists()) {
                        throw new Error(`Document at path "${path}" does not exist.`);
                    }
                    return {
                        data: snap.data() as T,
                        error: null
                    };

                } catch (e) {
                    
                    return {
                        data: null,
                        error: e
                    };
                }
            }
        }
    }
);

Breakdown

  1. First, we import our firebase config and parse the JSON data so it can be read.
  2. firebase-app makes sure we initialize Firebase only once.
  3. firebase-auth will return null if on the server. We only need to use it on the browser.
  4. firebase-firestore has two versions. The Firestore Lite package at firebase/firestore/lite will be faster, does not require TCP and will run in any environment. This means if you deploy to Vercel Edge, Deno, Cloudflare or Bun, it will still work!
  5. firestore-get-doc is just a shortcut for us to get rid of the boilerplate of asynchronously fetching a document.

Authentication

We need an authentication service to handle login actions. This is only used on the client.

import {
    DestroyRef,
    InjectionToken,
    inject,
    isDevMode,
    signal
} from '@angular/core';
import {
    GoogleAuthProvider,
    User,
    onIdTokenChanged,
    signInWithPopup,
    signOut
} from 'firebase/auth';
import { FIREBASE_AUTH } from './firebase.service';

export interface userData {
    photoURL: string | null;
    uid: string;
    displayName: string | null;
    email: string | null;
};

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

            const auth = inject(FIREBASE_AUTH);
            const destroy = inject(DestroyRef);

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

            // server environment
            if (!auth) {
                user.set({
                    data: null,
                    loading: false,
                    error: null
                });
                return user;
            }

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

            const unsubscribe = onIdTokenChanged(auth,
                (_user: User | null) => {

                    if (!_user) {
                        user.set({
                            data: null,
                            loading: false,
                            error: null
                        });
                        return;
                    }

                    // map data to user data type
                    const {
                        photoURL,
                        uid,
                        displayName,
                        email
                    } = _user;
                    const data = {
                        photoURL,
                        uid,
                        displayName,
                        email
                    };

                    // print data in dev mode
                    if (isDevMode()) {
                        console.log(data);
                    }

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

                    // handle error
                    user.set({
                        data: null,
                        loading: false,
                        error
                    });

                });

            destroy.onDestroy(unsubscribe);

            return user;
        }
    }
);

export const LOGIN = new InjectionToken(
    'LOGIN',
    {
        providedIn: 'root',
        factory() {
            const auth = inject(FIREBASE_AUTH);
            return () => {
                if (!auth) {
                    return null;
                }
                return signInWithPopup(
                    auth,
                    new GoogleAuthProvider()
                );
            };
        }
    }
);

export const LOGOUT = new InjectionToken(
    'LOGOUT',
    {
        providedIn: 'root',
        factory() {
            const auth = inject(FIREBASE_AUTH);
            return () => {
                if (!auth) {
                    return null;
                }
                return signOut(auth);
            };
        }
    }
);

Breakdown

  1. USER will use onIdTokenChanged to get the latest user state and set it to a signal that is updated in real time. You can check loading states or errors. It gets destroyed when the component is unmounted by using onDestroy.
  2. LOGIN is a callable function to login with the Google provider. It is meant to be called from a button on the client.
  3. LOGOUT is the logout method that destroys the session on the client. It is also meant to be called from the client only.

Layout

We can edit the app.ts file to get our layout.

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

@Component({
  selector: 'app-root',
  imports: [RouterOutlet, RouterLink],
  template: `
  <div class="pt-5">
    <router-outlet />
  </div>  
  <nav class="flex gap-3 justify-center mt-5">
      <a routerLink="/">Home</a>
      <a routerLink="/about">About</a>
      <a routerLink="/wall">Login Wall</a>
  </nav>
  `,
})
export class AppComponent { }
  1. Home will display our login state and profile.
  2. About will display the About information from the server OR the client, whether or not we are logged in. It also populates the schema and meta tags so that it can be SEO friendly.
  3. Login Wall will ONLY work when a user is logged in. You do not need to load data directly from the server, as Firebase has Firebase Rules for that, and SEO is unnecessary behind a login wall.

Home

For the home component, we only need to inject the user and login tokens from our auth service.

home.component.ts

import { Component, inject } from '@angular/core';
import { ProfileComponent } from '@components/profile/profile.component';
import { LOGIN, USER } from '@lib/firebase/auth.service';

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

home.component.html

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

    @if (user().loading) {

    <p>Loading...</p>

    } @else if (user().data) {

    <app-profile />

    } @else if (user().error) {

    <p class="text-red-500">Error: {{ user().error?.message }}</p>

    } @else {

    <button type="button" class="border p-2 rounded-md text-white bg-red-600" (click)="login()">
        Signin with Google
    </button>

    }
</div>

We display errors when necessary, and we show the app-profile component on success.

Profile

Because we are logged in already, we only need to show logout and user info.

profile.component.ts

import { Component, inject } from '@angular/core';
import { LOGOUT, USER } from '@lib/firebase/auth.service';

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

profile.component.html

<div class="flex flex-col gap-3 items-center">
    <h3 class="font-bold">Hi {{ user().data?.displayName }}!</h3>
    <img [src]="user().data?.photoURL" width="100" height="100" alt="user avatar" />
    <p>Your userID is {{ user().data?.uid }}</p>
    <button type="button" class="border p-2 rounded-md text-white bg-lime-600" (click)="logout()">
        Logout
    </button>
</div>

Buttons can only be called on the server.

About Data

Our About data is the key to SEO.

About Resolver

We need to force our Angular component to wait for our About data to be fetched. The correct place to do this is in an Angular Resolver, although you can use Pending Tasks in more complex situations.

about-data.resolver.ts

import { inject, isDevMode } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
import { ResolveFn } from '@angular/router';
import { FIRESTORE_GET_DOC } from '@lib/firebase/firebase.service';
import { useAsyncTransferState } from '@lib/utils';
import { Schema } from './schema.service';

export type AboutDoc = {
    name: string;
    description: string;
};

export const aboutDataResolver: ResolveFn<AboutDoc> = async () => {

    return useAsyncTransferState('about', async () => {

        const getDoc = inject(FIRESTORE_GET_DOC);

        const meta = inject(Meta);
        const title = inject(Title);
        const schema = inject(Schema);

        const {
            data,
            error
        } = await getDoc<AboutDoc>('/about/ZlNJrKd6LcATycPRmBPA');

        if (error) {
            throw error;
        }

        if (!data) {
            throw new Error('No data found');
        }

        title.setTitle(data.name);
        meta.updateTag({
            name: 'description',
            content: data.description
        });
        schema.setSchema({
            '@context': 'https://schema.org',
            '@type': 'WebPage',
            name: data.name,
            description: data.description
        });

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

        return data;
    });

};

This resolver uses all our utility files in one go. We fetch our Firestore document by providing the path, set the title, meta tag and schema before returning the data. We do this in a transfer state so that we only have to fetch the data once on the server. Bing!

about-data.component.ts

We inject our transfer data from the resolver and display it.

import { Component } from '@angular/core';
import { AboutDoc } from './about-data.resolver';
import { injectResolver } from '@lib/utils';
import { AsyncPipe } from '@angular/common';

@Component({
    selector: 'app-about-data',
    standalone: true,
    imports: [AsyncPipe],
    template: `
    @if (about | async; 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>
    <p class="text-center">
        <a href="https://validator.schema.org/#url=https%3A%2F%2Fultimate-analog-firebase.vercel.app%2Fabout" target="_blank" class="text-blue-600 underline">Validate Schema.org Metadata</a>
    </p>
    }
    `
})
export default class AboutDataComponent {
    about = injectResolver<AboutDoc>('data');
}

We must import the AsyncPipe and add it to an @if statement. Then we can display our data. The server will wait.

📝 Notice we use a standalone component here. Sometimes it is good to mix and match depending on readability or preference.

Login Wall

We do not want to fetch on the server, but on the client only. The resource directive gives us the perfect tools to handle loading and error states.

import { Component, inject, isDevMode, resource } from '@angular/core';
import { FIRESTORE_GET_DOC } from '@lib/firebase/firebase.service';
import { FirebaseError } from 'firebase/app';

export type WallDoc = {
    name: string;
    description: string;
};

@Component({
    selector: 'app-wall-data',
    standalone: true,
    imports: [],
    template: `
    @if (wall.isLoading()) {
    <div class="flex items-center justify-center my-5">
        <h1 class="text-3xl font-semibold">Loading...</h1>
    </div>
    } @else if (wall.status() === 'resolved') {
    <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">{{ wall.value()?.name }}</h1>
            <p>{{ wall.value()?.description }}</p>
        </div>
    </div>
    } @else if (wall.status() === 'error') {
    <div class="flex items-center justify-center my-5">
        @if (wall.error()?.message === 'permission-denied') {
            <h1 class="text-3xl font-semibold">You must be logged in to view this!</h1>
        } @else {
            <h1 class="text-3xl font-semibold">An error occurred: {{ wall.error()?.message }}</h1>
        }
    </div>
    } @else {
    <div class="flex items-center justify-center my-5">
        <h1 class="text-3xl font-semibold">You must be logged in to view this!</h1>
    </div>
    }
    `
})
export default class WallDataComponent {

    getDoc = inject(FIRESTORE_GET_DOC);

    wall = resource({

        loader: async () => {

            const {
                data,
                error
            } = await this.getDoc<WallDoc>('/secret/tJKWxu0ls6R0RyH1Atpb');

            if (error) {

                if (error instanceof FirebaseError) {

                    if (error.code === 'permission-denied') {
                        throw new Error(error.code);
                    }

                    console.error(error);
                    throw error;
                }
            }

            if (!data) {
                throw new Error('No data returned');
            }

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

            return data;
        }
    });
}
  • We fetch our document in resource({ loader: ... });.
  • We throw and error to use it in our template.
  • Our signals are:
    • wall.isLoading()
    • wall.status() === 'resolved'
    • wall.status() === 'error'
  • We check for the specific permission-denied error to show our not logged-in message.

Firebase Rules

For our login wall, we can create a Firestore Rule to prevent a document from being read unless the user is logged in. We could equally add roles, etc.

service cloud.firestore {

  match /databases/{database}/documents {
    match /{document=**} {
    
      match /secret/{document} {
      	allow read: if request.auth != null;
      }
      
      ...

Final Thoughts

I firmly believe this is all you need for 99% of Firebase apps. I have written articles on Firebase Admin Setup and Service Worker with Firebase Lite if you want to go down that rabbit hole, but you really don’t need them.

📝 You also really don’t need real-time data unless you’re building a chat app or using notifications, but that can be easily added.

This basic setup, using Angular 20+, signals, pure Firebase, Firestore Lite and clean injection tokens, should cover all your needs.

Repo: GitHub
Demo: Vercel Edge Functions


About the Author

Jonathan Gamble

Jonathan Gamble has been an avid web programmer for more than 20 years. He has been building web applications as a hobby since he was 16 years old, and he received a post-bachelor’s in Computer Science from Oregon State. His real passions are language learning and playing rock piano, but he never gets away from coding. Read more from him at https://code.build/.

 

 

Related Posts