DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

Angular 20: Real-World Auth & Data Patterns with `rxResource`, Tailwind v4 & daisyUI 5

Angular 20: Real-World Auth & Data Patterns with  raw `rxResource` endraw , Tailwind v4 & daisyUI 5

Angular 20: Real-World Auth & Data Patterns with rxResource, Tailwind v4 & daisyUI 5

Introduction

Angular 20 introduces the new build system (@angular/build), Tailwind v4 zero-config integration, and a refined Resource API for reactive data using Signals — replacing request/loader with params/stream.

This article explores a real-world auth-enabled store app (Teslo | Shop) using:

  • Functional guards (CanMatchFn)
  • Signal-based AuthService with rxResource
  • Tailwind v4 + daisyUI 5 styling
  • Modern Angular 20 templates with @if/@for

1️⃣ Auth Service with Signals + rxResource

import { HttpClient } from '@angular/common/http';
import { computed, inject, Injectable, signal } from '@angular/core';
import { environment } from 'src/environments/environment';
import { catchError, map, Observable, of, tap } from 'rxjs';
import { rxResource } from '@angular/core/rxjs-interop';

import { AuthResponse } from '@auth/interfaces/auth-response.interface';
import { User } from '@auth/interfaces/user.interface';

type AuthStatus = 'checking' | 'authenticated' | 'not-authenticated';
const baseUrl = environment.baseUrl;

@Injectable({ providedIn: 'root' })
export class AuthService {
  private _authStatus = signal<AuthStatus>('checking');
  private _user = signal<User | null>(null);
  private _token = signal<string | null>(localStorage.getItem('token'));

  private http = inject(HttpClient);

  checkStatusResource = rxResource({
    stream: () => this.checkStatus(),
  });

  authStatus = computed<AuthStatus>(() => {
    if (this._authStatus() === 'checking') return 'checking';

    if (this._user()) {
      return 'authenticated';
    }

    return 'not-authenticated';
  });

  user = computed(() => this._user());
  token = computed(this._token);
  isAdmin = computed(() => this._user()?.roles.includes('admin') ?? false);

  login(email: string, password: string): Observable<boolean> {
    return this.http
      .post<AuthResponse>(`${baseUrl}/auth/login`, {
        email: email,
        password: password,
      })
      .pipe(
        map((resp) => this.handleAuthSuccess(resp)),
        catchError((error: any) => this.handleAuthError(error))
      );
  }

  checkStatus(): Observable<boolean> {
    const token = localStorage.getItem('token');
    if (!token) {
      this.logout();
      return of(false);
    }

    return this.http
      .get<AuthResponse>(`${baseUrl}/auth/check-status`, {
        // headers: {
        //   Authorization: `Bearer ${token}`,
        // },
      })
      .pipe(
        map((resp) => this.handleAuthSuccess(resp)),
        catchError((error: any) => this.handleAuthError(error))
      );
  }

  logout() {
    this._user.set(null);
    this._token.set(null);
    this._authStatus.set('not-authenticated');

    localStorage.removeItem('token');
  }

  private handleAuthSuccess({ token, user }: AuthResponse) {
    this._user.set(user);
    this._authStatus.set('authenticated');
    this._token.set(token);

    localStorage.setItem('token', token);

    return true;
  }

  private handleAuthError(error: any) {
    this.logout();
    return of(false);
  }
}

Enter fullscreen mode Exit fullscreen mode

2️⃣ Functional Guards in Angular 20

import { inject } from '@angular/core';
import { CanMatchFn, Route, Router, UrlSegment } from '@angular/router';
import { AuthService } from '@auth/services/auth.service';
import { firstValueFrom } from 'rxjs';

export const IsAdminGuard: CanMatchFn = async (
  route: Route,
  segments: UrlSegment[]
) => {
  const authService = inject(AuthService);

  await firstValueFrom(authService.checkStatus());

  return authService.isAdmin();
};

Enter fullscreen mode Exit fullscreen mode

Angular 20 favors function-based guards — concise, tree-shakable, and perfect for Signals.


3️⃣ HTTP Interceptor for Tokens

import { HttpHandlerFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { AuthService } from '@auth/services/auth.service';

export function authInterceptor(
  req: HttpRequest<unknown>,
  next: HttpHandlerFn
) {
  const token = inject(AuthService).token();

  const newReq = req.clone({
    headers: req.headers.append('Authorization', `Bearer ${token}`),
  });
  return next(newReq);
}

Enter fullscreen mode Exit fullscreen mode

4️⃣ Login Page (Reactive Form + Signals)

import { Component, inject, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';

import { AuthService } from '@auth/services/auth.service';

@Component({
  selector: 'app-login-page',
  imports: [RouterLink, ReactiveFormsModule],
  templateUrl: './login-page.component.html',
})
export class LoginPageComponent {
  fb = inject(FormBuilder);
  hasError = signal(false);
  isPosting = signal(false);
  router = inject(Router);

  authService = inject(AuthService);

  loginForm = this.fb.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(6)]],
  });

  onSubmit() {
    if (this.loginForm.invalid) {
      this.hasError.set(true);
      setTimeout(() => {
        this.hasError.set(false);
      }, 2000);
      return;
    }

    const { email = '', password = '' } = this.loginForm.value;

    this.authService.login(email!, password!).subscribe((isAuthenticated) => {
      if (isAuthenticated) {
        this.router.navigateByUrl('/');
        return;
      }

      this.hasError.set(true);
      setTimeout(() => {
        this.hasError.set(false);
      }, 2000);
    });
  }


}

Enter fullscreen mode Exit fullscreen mode

5️⃣ Navbar with Auth-Driven UI

<div class="navbar bg-base-100">
  <div class="navbar-start">
    <a routerLink="/" class="btn btn-ghost text-xl font-montserrat">
      Teslo<span class="text-secondary">|Shop</span>
    </a>
  </div>
  <div class="navbar-end gap-4">
    @if (authService.authStatus() === 'authenticated') {
      <button class="btn btn-ghost">{{ authService.user()?.fullName }}</button>
      <button class="btn btn-sm btn-error" (click)="authService.logout()">Salir</button>
    }
    @else if (authService.authStatus() === 'not-authenticated') {
      <a routerLink="/auth/login" class="btn btn-secondary">Login</a>
    }
    @else {
      <a class="btn btn-ghost">...</a>
    }
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

6️⃣ Admin Layout (Tailwind v4 + daisyUI)

<div class="bg-slate-800 h-screen text-slate-300">
  <div class="flex">
    <aside class="bg-gray-900 w-64 h-screen p-6">
      <h1 class="text-2xl font-bold">Teslo<span class="text-blue-500">|Shop</span></h1>
      <p class="text-slate-500 text-sm">Welcome, {{ user()?.fullName }}</p>
      <a routerLink="/admin/products" class="block py-3 hover:bg-white/5">Products</a>
      <button class="btn btn-ghost text-accent w-full mt-6" (click)="authService.logout()">Log out</button>
    </aside>
    <main class="flex-1 p-5 overflow-y-auto"><router-outlet /></main>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

7️⃣ Migration Notes (v19 → v20)

Old New Description
request params reactive fetch input
loader stream observable/promise factory
ResourceStatus `'idle' 'loading'
{% raw %}@if/@for new control flow built-in syntax

💡 Expert Takeaways

  • rxResource<T, P> gives typed results, cancelable requests, and easy reloads.
  • Provide defaultValue to avoid undefined guards.
  • HttpClient supports { signal: abortSignal }.
  • Functional guards reduce boilerplate.
  • Tailwind v4 + daisyUI v5 bring modern UI theming.

📚 Resources


Next up: Streaming Data with rxResource.stream() and SSE in Angular 20 🚀

Top comments (0)