Angular SSR/SSG App with Supabase — Auth, Guards, Tests, API calls Part 1/2(?)
Refactoring Supabase Auth in Angular: Layers, SSR, and Tests
Angular SSR/SSG App with Supabase — Auth, Guards, Tests, API calls Part 1/2(?)

Refactoring Supabase Auth in Angular: Layers, SSR, and Tests
In the previous part, I described several issues with Supabase integration in my Angular blog: a freezing app under SSR/SSG, broken pre-render data fetching, problems with guards, and general confusion around auth initialization.
The biggest issue is the Supabase createClient() method itself — it doesn't return anything, it's fire-and-forget. Compare this to Keycloak, where createClient is Promise-based and you have a clear async signal to wait on.
The current code was autogenerated by Claude Code, and all the logic ended up inside a SignalStore. This is a fundamental mistake.
Application Layers
A well-designed application should be split into layers. In this case, three layers were mixed:
- Infrastructure layer — a bridge to the domain layer. This is where SDKs like Supabase live, along with things like Angular’s Router. The infrastructure should be separated from the domain. If the Supabase SDK leaks into the rest of the app, it becomes a third-party dependency that’s harder to swap, harder to test, and harder to mock.
- State layer — the SignalStore. It keeps application state and communicates with infrastructure, but does not implement business logic. It also shouldn’t depend on infrastructure directly — wrappers are a better option, and I’ll show how and why.
- Domain layer — the application’s business logic. It should be framework-agnostic (which is hard to achieve in practice) but, more importantly, independent from infrastructure. Want to switch from Supabase to Keycloak? From Supabase API to your own .NET backend? In a properly layered app, you swap the bridge to infrastructure and the rest stays the same. In gigantic enterprise apps this rarely happens in practice, but in smaller projects like this one, it absolutely can. Supabase is not free, alternatives exist, and prices change.
The Admin Guard Problem
When I started writing better code than the autogenerated version, I ran into the first real problem: a guard for the admin app.
We need to block all traffic inside the admin app for non-admin users. The catch is that the Supabase session is initialized asynchronously, under the hood. We can await it manually, of course.
I asked Claude, Gemini, Codex, and other LLMs for help. Two suggestions came up over and over:
await getSession()inside the guard.authStore.init()in the app initializer.
Both have issues.
The first one — await getSession() — is fine in principle. In a commercial app, I'd use it in the app initializer to block bootstrap until auth is ready. It's a quick win. But it's not elegant, and I don't want it in a guard. Why? Because the goal is to stay provider-agnostic. Every guard would need to implement the same pattern, leading to duplication. Infrastructure leaks into the domain. And if you ever switch providers — say, to one that doesn't expose a session or doesn't return a Promise — you'll need wrappers and mappers, which means more code to maintain.
The second one — using SignalStore lifecycle hooks — is more subtle. SignalStore exposes onInit and onDestroy, but they only work in a component context, because SignalStore is component-scoped by design. There's an open issue asking for something like a constructor on SignalStore, and I suspect this restriction is intentional — to prevent exactly the patterns LLMs keep proposing. The chats pushed this as the one and only solution, but when asked to actually implement it, they couldn't. SignalStore is for state, not for app initialization. It was designed to enforce clean layer separation, not to be a swiss army knife.
Supabase Froze the App
I had an issue where Supabase was freezing the entire Angular app with SSR or SSG enabled. The root cause: we were using createClient() instead of createServerClient() from @supabase/ssr. Supabase stores the session in localStorage, which doesn't exist server-side. To make server-side auth work properly, the session needs to travel between client and server via cookies.
My initial quick fix involved runOutsideAngular, which helped me move forward, but I had already moved most server-side data fetching to PostgREST calls via Angular's HttpClient. I wasn't using the Supabase client server-side at all. Once I realized this, the proper fix turned out to be surprisingly simple — don't create the client on the server at all:
export function createSupabaseClient(): SupabaseClient | null {
const config = inject(SUPABASE_CONFIG);
const platform = inject(PLATFORM_ID);
return isPlatformServer(platform)
? null
: createClient(config.supabaseUrl, config.supabaseKey);
}
Issue gone. Supabase no longer freezes the app.
My Approach to Supabase Auth
I wouldn’t call this the best approach — I’d call it good. Bad code is code that doesn’t work. The worst code is code that works but nobody knows why. We live in a world of trade-offs.
I spent a lot of time thinking about how to handle auth properly.
As mentioned earlier, in a commercial app I would use await getSession() in the app initializer and block bootstrap until the Supabase session is ready. Then I'd have the user and their roles available everywhere — everything needed to decide who gets access where. On a blog like this one, we can often skip that. Most of the time, the session will initialize before we even need to make a user-specific API call. There might be edge cases where getProfile fires too early, but every app has bugs. A one-hour fix that makes the app work almost perfectly versus a thirty-hour fix that covers 0.01% of edge cases — I'm choosing the thirty-hour path. Why? Because I want to try to build the best solution. I've gone down rabbit holes many times during this fix. Sometimes a solution broke something else. From a "programming as an art" perspective, this is the better path. From a business perspective, it's the worse one.
I’ll probably move away from Supabase eventually — it’s expensive for a blog. So I want to be conscious about every dependency I take on.
Creating the Client
In the current codebase, the client is created in the app initializer via a service constructor. That makes the service the kickstarter for the app — but it handles far too much: creating the client, handling auth changes, handling session changes. In most cases where SupabaseService is injected, all we actually need is the client itself.
This is where one of Angular’s best features comes in: dependency injection. It’s underused. I rarely see injection tokens in the wild; most people use DI only to inject services. That’s it. But DI is powerful precisely because it lets us depend on abstractions, not concrete implementations. We can decouple parts of the application. Why does that matter? For testing — unit, e2e, and A/B testing — and for things like providing different clients for different routes:
// app.routes.ts
import { Routes } from '@angular/router';
import {
SUPABASE_CONFIG,
SUPABASE_CLIENT,
SupabaseService,
createSupabaseClient,
} from '@shared/core/supabase';
export const routes: Routes = [
{
path: '',
loadChildren: () => import('./features/main-page/main-page.routes'),
},
{
path: 'post',
providers: [
{
provide: SUPABASE_CONFIG,
useValue: {
supabaseUrl: 'https://other-project.supabase.co',
supabaseKey: 'public-anon-key-for-that-project',
},
},
{
provide: SUPABASE_CLIENT,
useFactory: createSupabaseClient,
},
SupabaseService,
],
loadChildren: () => import('./features/post/post.routes'),
},
{
path: '**',
redirectTo: '',
},
];
With this in place, a single route can talk to a completely different Supabase project. Want to ship a new feature but unsure if it justifies a separate paid tier? Use a different client with limitations on that route. Once it’s proven, swap back to the main provider without touching any other code. Want to test specific scenarios? Provide your own implementation of the client and verify the real data flow through your services, mocking only the third-party library at the edge.
The actual implementation:
export function provideCore({ routes }: CoreOptions) {
return [
provideBrowserGlobalErrorListeners(),
provideHttpClient(withFetch()),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes, withComponentInputBinding()),
provideClientHydration(withEventReplay()),
{
provide: SUPABASE_CONFIG,
useValue: {
supabaseUrl: environment.supabaseUrl,
supabaseKey: environment.supabaseKey,
},
},
{
provide: SUPABASE_CLIENT,
useFactory: createSupabaseClient,
},
provideAppInitializer(supabaseInitializer),
];
}
export const SUPABASE_CLIENT = new InjectionToken<SupabaseClient>('SupabaseClient');export function createSupabaseClient(): SupabaseClient {
const config = inject(SUPABASE_CONFIG);
const platform = inject(PLATFORM_ID);
const zone = inject(NgZone);
// TODO: Implement server-side authentication logic with SSR and cookies/headers
return isPlatformServer(platform)
? zone.runOutsideAngular(() => createClient(config.supabaseUrl, config.supabaseKey))
: createClient(config.supabaseUrl, config.supabaseKey);
}
I’m creating the client on the server as well in this version. That’s because I later forced the native Supabase client to fetch data for SSR/SSG — I’ll come back to that in the bonus section.
Consuming the client is now trivial:
private readonly client = inject(SUPABASE_CLIENT);
The rest of the app stays the same. No changes needed. This is the real power of DI.
Initializing Auth
In core.ts we have:
provideAppInitializer(supabaseInitializer),
Let’s rename it:
provideAppInitializer(authInitializer),
This reads better — Supabase is already initialized via the client provider; what we’re really initializing here is auth. The initializer function is a thin wrapper around the service:
export function authInitializer(): void {
const supabaseService = inject(SupabaseService);
supabaseService.initializeAuth();
}
From the Supabase docs:
The session returned can be
nullif the session is not detected, which can happen if a user is not signed-in or has logged out.
This is convenient. I ran a few tests, and the session also returns null when the client itself has issues, such as a wrong API key. This makes the implementation more resilient to errors — we can rely on the session.
The session is great, but it lives in browser memory, and the role inside it can be tampered with. The docs recommend:
const { data: { user } } = await supabase.auth.getUser()
Should always be used when checking for user authorization on the server. On the client, you can use
getSession().session.userfor faster results.getSessionis insecure on the server.
Important security notice: If using an insecure storage medium such as cookies or request headers, the user object returned by this function must not be trusted. Always verify the JWT using
getClaims()or your own JWT verification library to securely establish the user's identity and access. You can also usegetUser()to fetch the user object directly from the Auth server for this purpose.
Why is this more important on the server? Because right now, a user could open DevTools, modify the role stored in localStorage, and access the admin panel UI. But this is always possible — you cannot prevent the frontend from being inspected. You could keep the JS code on the backend, gate it behind a JWT check, and only send it to authorized users. But there's no real point. We have Row-Level Security to block API calls, and the frontend code is in a public GitHub repo anyway.
The LLM-proposed await getSession() in guards has another problem. From the docs:
It’s best practice and highly recommended to extract the access token (JWT) and store it in memory for further use in your application. Avoid frequent calls to
supabase.auth.getSession()for the same purpose.
A quick benchmark confirms this — the first getSession() call takes some time, subsequent ones are fast. But:
this function is synchronized across all tabs using the LockManager API
With multiple tabs open, the latency increases even on non-first calls.
This isn’t only about micro-optimization, though — it’s about how the app is structured. We can extract the token and pass it through the application. The JWT is a common abstraction across auth providers, so business logic operates on the JWT and its claims and doesn’t care about who issued it. Supabase, Auth0, Keycloak, Azure AD, your own .NET API — a JWT is a JWT.
Extracting Data From Supabase
The Supabase session object looks like this:
{
"data": {
"session": {
"access_token": "<ACCESS_TOKEN>",
"token_type": "bearer",
"expires_in": 3600,
"expires_at": 1700000000,
"refresh_token": "<REFRESH_TOKEN>",
"user": {
"id": "11111111-1111-1111-1111-111111111111",
"aud": "authenticated",
"role": "authenticated",
"email": "example@email.com",
"app_metadata": {
"provider": "email",
"providers": ["email"]
},
"user_metadata": {
"email": "example@email.com",
"email_verified": false,
"phone_verified": false,
"sub": "11111111-1111-1111-1111-111111111111"
},
"identities": [ /* ... */ ],
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"is_anonymous": false
}
}
},
"error": null
}
Most of this isn’t relevant right now. YAGNI applies — You Aren’t Gonna Need It.
What we need is the user role (our own role, stored in user_metadata, not the built-in Supabase role) and the user ID. Both live on the User object, but the default Supabase User type doesn't include metadata typing, so we extend it to UserWithRole.
export class UserService {
private readonly appUser = new BehaviorSubject<UserWithRole | null | undefined>(undefined);
get appUser$(): Observable<UserWithRole | null> {
return this.appUser.asObservable().pipe(filter(user => user !== undefined));
}
setAppUser(user: UserWithRole | null): void {
this.appUser.next(user);
}
}
I’ll show a cleaner approach later. For now, we’re pushing a user object derived directly from the Supabase user. This isn’t ideal — a generic mapper would be better, since we only really need id and a Role array.
What this gives us is auth data as a store that doesn’t depend on the provider. It can be used with any provider, and the rest of the app sees the same shape regardless of where the data comes from. appUser$ is trivial to mock in tests — unit tests with a user, guard tests, component tests with routing, different roles, different scenarios. All driven by mocking appUser$, not the entire Supabase auth flow.
A quick breakdown of the design choices:
Why BehaviorSubject? We need an initial value and we need new subscribers to receive the current value immediately on subscription, without waiting for the next emission.
Why null | UserWithRole | undefined? This maps directly to the three auth states:
undefined— auth hasn't initialized yet, we don't know anything. This is the initial value. We filter it out from the public stream so subscribers never observe this transient state.null— auth is initialized, no user is logged in. The session check came back empty. This is a known state — guards redirect to login, components show signed-out UI.UserWithRole— auth is initialized and we have a user with their role mapped.
undefined means "not ready", null means "ready, anonymous", UserWithRole means "ready, authenticated". The filter(user => user !== undefined) ensures consumers only ever see the two ready states.
The Async Admin Guard
Earlier I mentioned that LLMs tried to convince me that blocking app bootstrap is a bug. I shouldn’t have framed it that way — both approaches are valid. And in a scenario where the entire app is gated behind auth, there’s not much point in handling auth errors gracefully. If auth fails, there is no app. This is the typical admin panel scenario, and UX there is usually a secondary concern, sometimes not a concern at all.
There’s no single correct approach. It’s a matter of design choices. Blocking app bootstrap is the easier path.
I chose the harder one:
export const authAdminGuard: CanMatchFn = (): Observable<boolean | UrlTree> => {
const userService = inject(UserService);
const router = inject(Router);
return userService.appUser$.pipe(
take(1),
map(user => {
if (user?.app_metadata.role === Roles.ADMIN) {
return true;
}
return router.createUrlTree(['/login']);
})
);
};
Async guards in modern Angular can return an Observable. We subscribe to appUser$. Since undefined is filtered out, the guard will naturally wait for auth to resolve before evaluating. The session check is error-proof and quick, so there's no risk of being stuck indefinitely. We use take(1) because we only care about the first decisive emission, and we want to unsubscribe immediately after.
Handling the User Profile
With auth in place, we can build a simple store for the user profile:
withMethods(
(store, profileService = inject(ProfileService)) => ({
loadProfile: rxMethod<string | null>(
pipe(
distinctUntilChanged(),
tap(() => patchState(store, { loading: true, error: null })),
switchMap(id => {
if (!id) {
patchState(store, { userProfile: null, loading: false });
return of(null);
}
return profileService.getProfile(id).pipe(
tapResponse({
next: profile => patchState(store, { userProfile: profile, loading: false }),
error: (err: unknown) =>
patchState(store, {
userProfile: null,
loading: false,
error: `Failed to fetch profile: ${err instanceof Error ? err.message : String(err)}`,
}),
})
);
})
)
),
})
),
withHooks({
onInit(store, userService = inject(UserService)) {
store.loadProfile(userService.appUser$.pipe(map(u => u?.id ?? null)));
},
})
This is a draft — I’ll refactor all stores together later. The point is that we can rely on the observable, and the store will react automatically when user.id changes. For testing, we have two options: mock the store entirely (harder to set up cleanly), or use the actual store with the component and mock only appUser$. The second approach is often the better one, since it exercises the real data flow.
The Navbar Flicker
When refreshing a page with a logged-in user, the navbar flickers — the Sign In button briefly appears before flipping to Sign Out and showing the user data. This happens because the server-side rendering doesn’t have access to the session. We didn’t set up a proper server-side Supabase client, and we don’t push the session or JWT to the server, so server-side API calls can’t be authenticated. I’ll address this in the next part.
I really wanted this front page to be SSG. In React, there’s the server components model — part of a page can be statically generated, and individual components can be server-rendered per request, which would fit this use case perfectly. Unfortunately, Angular and AnalogJS don’t support this pattern today.
Bonus: Forcing the Supabase Client to Fetch Data for Server-Side Generation
A short bonus section. I previously had problems with data fetched through the native Supabase client during SSR/SSG — Angular ignored the Supabase calls and didn’t wait for them before serializing the HTML. I worked around this by routing those calls through PostgREST and Angular’s HttpClient, which integrates with Angular's pending tasks service. HttpClient registers itself with the pending tasks mechanism, so Angular knows it has to wait.
All my data is now exposed as Observables rather than the Promises that Supabase returns natively. (I dislike Promises — they’re broken too often.) Stores consume Observables. This is more idiomatic in Angular, and it turned out to be useful for SSR.
Angular v20 introduced a new rxjs-interop operator:
**pendingUntilEvent**
Operator which makes the application unstable until the observable emits, completes, errors, or is unsubscribed. Use this operator in observables whose subscriptions are important for rendering and should be included in SSR serialization.
Under the hood, it calls taskService.add() — the same mechanism HttpClient uses. In principle, we could wrap Supabase Promises directly and achieve the same result, but using the operator is cleaner. I didn't even know it existed until I dug into the SSR docs.
getPosts(): Observable<Post[]> {
return from(
this.client
.from('posts')
.select('*, author:profiles(id,username,avatar_url), post_tags(tags(id,name,color,icon))')
.eq('is_draft', false)
.order('created_at', { ascending: false })
).pipe(
map(x => (x.error ? [] : x.data)),
pendingUntilEvent()
);
}
With this in place, the Supabase client works correctly under SSR. I haven’t benchmarked it against the PostgREST + HttpClient approach yet, but I'm happy with the result.
Tests
Architecture decisions are only valuable if we can verify them. I split tests into two parts: unit tests (Karma + Jasmine) and end-to-end tests (Playwright).
Unit Tests
Guard test — auth-admin.guard.spec.ts
The guard is pure logic: take the first emission from appUser$, check the role, return true or a UrlTree. This is an ideal unit test target — it has exactly one dependency (UserService), and we fully control its output.
const setup = (appUser$: Observable<UserWithRole | null>) => {
TestBed.configureTestingModule({
providers: [
provideZonelessChangeDetection(),
provideRouter([]),
{ provide: UserService, useValue: { appUser$ } },
],
});
const router = TestBed.inject(Router);
const createUrlTreeSpy = spyOn(router, 'createUrlTree').and.callThrough();
const runGuard = () =>
TestBed.runInInjectionContext(
() => authAdminGuard({} as Route, []) as Observable<boolean | UrlTree>
);
return { router, createUrlTreeSpy, runGuard };
};
A few details worth highlighting:
- We provide
UserServicewith just{ appUser$ }. The guard touches nothing else, so there's no reason to mock methods we never call. runInInjectionContextis required becauseauthAdminGuardusesinject()internally. Without it, you getNG0203: inject() must be called from an injection context.provideRouter([])gives us a realRouter. We spy oncreateUrlTreeto verify the call, but we don't replace the router itself. Cheap, and we get real behavior.
The tests themselves are straightforward — push different values through a Subject or ReplaySubject and assert the guard's response:
it('uses only the first emission from appUser$', async () => {
const appUser$ = new Subject<UserWithRole | null>();
const { createUrlTreeSpy, runGuard } = setup(appUser$); const result$ = firstValueFrom(runGuard());
appUser$.next(createUser(Roles.ADMIN));
appUser$.next(createUser(Roles.READER));
appUser$.next(null);
const result = await result$;
expect(result).toBe(true);
expect(createUrlTreeSpy).not.toHaveBeenCalled();
});
This verifies that take(1) does its job — admin emits first, the guard resolves with true, and the subsequent emissions are ignored. If take(1) were accidentally removed, this test would catch it.
Component test — navbar.component.spec.ts
This is where the choice between mocking the store and using the real store becomes practical. I included both approaches in the same spec file — they test different things.
The first describe block mocks ProfileStore directly:
profileStoreMock = {
userName: signal<string | undefined>(undefined),
};
Quick, isolated, and focused on the navbar’s reaction to signal changes. Good for verifying view logic.
The second describe block uses the real ProfileStore and mocks only UserService and ProfileService:
const userServiceMock = {
appUser$: of({ id: 'user-1' } as never),
setAppUser: jasmine.createSpy('setAppUser'),
};
profileServiceMock = {
getProfile: jasmine
.createSpy('getProfile')
.and.returnValue(of({ id: 'user-1', username: 'Lukasz' } as never)),
};
This tests the full chain: appUser$ emits, the real store reacts, getProfile is called, the store updates, and the navbar renders. Anything that breaks along that path will fail this test. The mock-store version wouldn't catch it.
Both approaches have their place. Pick the one that fits the question you’re asking.
End-to-End Tests (Playwright)
Unit tests verify pieces in isolation. End-to-end tests verify the whole thing in a real browser, against a real server, with real navigation. The challenge with auth-heavy apps is avoiding a full login in every single test.
Setup project and storageState
Playwright supports a setup project — a special project that runs once before the test suite and produces artifacts (such as a saved storage state file) that subsequent tests reuse. I have two: auth-web.setup.ts for a regular user and auth-admin.setup.ts for the admin.
setup('authenticate admin via login flow', async ({ page }) => {
await mockAuthenticatedUser(page, {
id: 'admin-1',
email: 'admin@example.com',
username: 'Admin',
role: 'Admin',
}); await page.goto('/');
await page.getByLabel('Email Address').fill('admin@example.com');
await page.getByLabel('Password').fill('admin123');
await page.getByLabel('Password').press('Enter');
await expect(page.getByRole('button', { name: 'Sign In' })).not.toBeVisible(); await page.context().storageState({ path: authFile });
});
Three things happen here:
- Mock the Supabase endpoints. Intercept
/auth/v1/token,/auth/v1/user, and/rest/v1/profiles, returning canned responses. No real network calls to Supabase. - Go through the actual login UI. Fill the form, submit. The Supabase client in the browser receives the mocked response and stores the session exactly as it would after a real login — cookies, internal state, all of it.
- Save the storage state to disk.
context().storageState({ path: authFile })dumps cookies andlocalStorageto a JSON file. Every subsequent test that uses this project starts with this state pre-loaded.
The result is the closest thing to a real login without hitting Supabase. The storage state isn’t faked by hand — the Supabase client builds it for us. We just persist what it produced.
The fake JWT
Since we mock the entire /auth/v1/token endpoint, the JWT we return doesn't need to be cryptographically valid. It just needs to look like one. In mockAuthenticatedUser, we build it manually:
const accessToken = [
toBase64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })),
toBase64Url(JSON.stringify({ aud: 'authenticated', sub: user.id, ... })),
'mock-signature',
].join('.');
Three base64-encoded parts separated by dots, a real-looking payload with sub, email, and exp, and a signature that's literally the string mock-signature. The Supabase client in the browser doesn't validate signatures — that's the server's job. And since we've replaced the server, validation never happens.
Test files
With the setup in place, the actual specs are concise:
admin.spec.ts— a single test that navigates to/postsand verifies the admin page renders. This is the integration smoke test for everything covered above: auth, guard, store, the lot. If it passes, the whole chain works.navbar-authenticated.spec.ts— verifies that on app start with a logged-in user, the navbar renders the authenticated state: Sign Out visible, Sign In gone, Hello, {username} rendered.login-modal.spec.ts— two tests: a failed login keeps the modal open and shows an error, and logging out clears the session and brings back the Sign In button.
There’s no “happy path login” test in login-modal.spec.ts, because that's exactly what the setup project does. Running the same scenario again in a regular spec would be duplication. The setup is the happy-path login test — if it fails, none of the other tests in that project run.
There’s also an older tags.spec.ts in the codebase. It works but predates the refactor — cleanup is on my list.
Closing Thoughts
This iteration of the blog isn’t perfect, and I know what I’d still change. The navbar flicker is the most visible issue, and it traces back to the fact that the server has no session. Solving that properly means pushing cookies between server and client, using createServerClient, and reworking how data is fetched during SSR. That's the next part.
The bigger lesson from this round: layering matters, dependency injection is underused, and provider-agnostic auth is worth the upfront cost when you suspect you’ll outgrow your current stack. Tests are not optional — they’re how you confirm your architectural decisions actually hold.
메타데이터
- post_id
- 1f20e0bee563
- slug
- angular-ssr-ssg-app-with-supabse-auth-guards-tests-api-calls-part-1-2-1f20e0bee563
- url
- https://medium.com/@lukaszlucky/angular-ssr-ssg-app-with-supabse-auth-guards-tests-api-calls-part-1-2-1f20e0bee563
- canonical_url
- https://medium.com/@lukaszlucky/angular-ssr-ssg-app-with-supabse-auth-guards-tests-api-calls-part-1-2-1f20e0bee563
- author_url
- https://medium.com/@lukaszlucky
- status
- ok
- fetched_at
- 2026-06-09 15:37:30