Chapter 8 of 13

Frontend Foundations: Angular 22, Zoneless, and a Missing Provider

With the whole backend request path provable end to end (auth a key, enforce limits, call Azure, log usage), I switched to the Angular side. Up to this point apps/web was still close to the Nx generator’s default output: an empty shell with no routes worth mentioning. This is the stage where it became an actual dashboard, starting with auth, routing, and dark mode, before any of the CRUD screens existed.

A flag that didn’t do anything

I generated the Angular app originally with --zoneless, assuming that flag alone would configure zoneless change detection. It doesn’t, not fully. When I actually sat down to wire up routing and guards, I noticed provideZonelessChangeDetection() was never registered in app.config.ts at all, despite the generator flag. I don’t know if that’s a gap in the generator version I used or something I missed during initial scaffolding, but either way, the fix was just adding it explicitly:

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideZonelessChangeDetection(),
    provideRouter(appRoutes),
    provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
    provideAppInitializer(() => inject(AuthService).restoreSession()),
  ],
};

Worth calling out: provideAppInitializer runs AuthService.restoreSession() before the router renders anything. That matters for a very specific case, reloading the page while already logged in. Without that initializer running first, the router could briefly try to render the dashboard with a stale or invalid token still sitting in localStorage, flash it, and then redirect to login once the /auth/me check failed. Blocking the initial render on that check means the flash never happens.

Auth as three small pieces

AuthService holds two signals, an access token and the current user, plus a computed isAuthenticated:

@Injectable({ providedIn: 'root' })
export class AuthService {
  readonly accessToken = signal<string | null>(
    localStorage.getItem(STORAGE_KEY),
  );
  readonly currentUser = signal<AuthenticatedAdmin | null>(null);
  readonly isAuthenticated = computed(() => this.accessToken() !== null);

  async login(email: string, password: string): Promise<void> {
    const response = await firstValueFrom(
      this.http.post<LoginResponse>(`${API_BASE_URL}/auth/login`, {
        email,
        password,
      }),
    );
    this.setToken(response.accessToken);
    await this.loadCurrentUser();
  }
}

No NgRx, no separate state library. For a dashboard with exactly one logged-in role and a handful of screens, plain signals on an injectable service are enough state management, and I didn’t want to reach for more infrastructure than the actual complexity called for.

API_BASE_URL is just /api, a relative path, not an absolute URL:

export const API_BASE_URL = '/api';

That one line matters more than it looks. In development, apps/web/proxy.conf.json forwards /api and /v1 requests to http://localhost:3700, so the dev server and the API don’t need to agree on CORS. In production, the same relative path works because Nginx, sitting in front of both containers, proxies /api/* straight to the API container on the same origin. I never had to think about CORS anywhere in this project because the request never crosses an origin boundary in the first place, dev or prod.

authInterceptor attaches the bearer token to outgoing API calls and clears the session on a 401:

export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);
  const token = authService.accessToken();

  const isApiRequest = req.url.startsWith(API_BASE_URL);
  const authorizedReq =
    isApiRequest && token
      ? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
      : req;

  return next(authorizedReq).pipe(
    catchError((error) => {
      if (
        isApiRequest &&
        error instanceof HttpErrorResponse &&
        error.status === 401
      ) {
        authService.logout();
      }
      return throwError(() => error);
    }),
  );
};

The isApiRequest check exists so this interceptor doesn’t accidentally attach an admin JWT to a request against the gateway’s own /v1 endpoints later on (the test chat feature calls those directly with a virtual key, not an admin token), and doesn’t force-logout on a 401 from some unrelated external call.

At this stage, the login page itself was intentionally plain: signals for email, password, submitting state, and an error message, with native (input) handlers reading $event.target.value, no FormsModule, no ngModel, and definitely no Signal Forms yet. I made that an explicit exception in my own head: the login form is the one form in this app simple enough not to need a real forms library, everything else (deployments, virtual keys) got Signal Forms from the start. The visual redesign onto ng-oat components came much later, in its own chapter.

Dark mode without Tailwind’s old config option

Tailwind v4 dropped the darkMode: 'class' config option from v3 in favor of a CSS-based custom variant:

@custom-variant dark (&:where(.dark, .dark *));

ThemeService reads a stored preference (or prefers-color-scheme if there isn’t one), keeps a signal for it, and toggles the dark class on <html> along with the native color-scheme CSS property so browser-native UI (scrollbars, form controls) matches too:

private applyToDocument(isDark: boolean): void {
  document.documentElement.classList.toggle('dark', isDark);
  document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
}

It also listens for OS-level theme changes via matchMedia, so if the preference is set to “system” and the OS switches themes while the tab is open, the app follows along without a reload.

With auth, routing, and theming working, the actual feature pages (dashboard, deployments, virtual keys, usage) were still one-line placeholders at this point. Building those out, and the architectural decision that came right after, is next.