-
Notifications
You must be signed in to change notification settings - Fork 82
Add SSR Skill #80
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add SSR Skill #80
Changes from 4 commits
a604ae8
8abdc85
0bfd4b7
7588713
57a9ff9
3501907
8ffb170
67536dc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,72 @@ | ||||||
| --- | ||||||
| name: firebase-ssr | ||||||
| description: "How to use Firebase in Server-Side Rendering (SSR) environments. Make sure to use this skill whenever the user mentions Next.js, Nuxt, SvelteKit, Angular SSR, Remix, or any other server-side framework, or asks about initializeServerApp, session cookies, fetching data on the server, or serializing Firebase data between server and client." | ||||||
| --- | ||||||
|
|
||||||
| # Firebase in SSR Environments | ||||||
|
|
||||||
| When building universal/SSR applications correctly, you must isolate Firebase apps to prevent cross-request state pollution and securely pass Firebase-specific data structures back to the client. | ||||||
|
|
||||||
| ## Framework Selection Workflow | ||||||
|
|
||||||
| The core concepts of Firebase SSR (request isolation and serialization mappings) apply to all major backend JS frameworks, but the execution syntax drastically changes. | ||||||
|
|
||||||
| **Step 1:** Identify the SSR framework the user is building with. | ||||||
|
|
||||||
| **Step 2:** Read the appropriate framework-specific reference guide before attempting to implement Firebase integration. | ||||||
| - `[references/nextjs.md](file:///Users/mtewani/source/agent-skills/skills/firebase-ssr/references/nextjs.md)` - For Next.js App Router (RSCs, Route Handlers). | ||||||
| - `[references/remix.md](file:///Users/mtewani/source/agent-skills/skills/firebase-ssr/references/remix.md)` - For Remix (`loader` / `action` functions). | ||||||
| - `[references/angular-ssr.md](file:///Users/mtewani/source/agent-skills/skills/firebase-ssr/references/angular-ssr.md)` - For Angular Universal/SSR (`REQUEST` token and `TransferState`). | ||||||
|
|
||||||
| *Note: If the user's framework is not explicitly listed (e.g., SvelteKit, Nuxt), read `nextjs.md` mentally translating Next-specific concepts (like `headers()`) to the equivalent request handling method corresponding to their actual framework.* | ||||||
|
|
||||||
| --- | ||||||
|
|
||||||
| ## Core Principles | ||||||
|
|
||||||
| Regardless of the framework selected in Step 2, you must aggressively enforce the following core principles. | ||||||
|
|
||||||
| ### 1. Initializing Firebase: Avoid the Singleton | ||||||
|
|
||||||
| In a Node.js SSR context, utilizing the single `initializeApp` singleton is extremely dangerous because the server instance is shared across all incoming requests globally. | ||||||
|
|
||||||
| > [!WARNING] | ||||||
| > DO NOT use the standard `initializeApp()` inside server-side code that responds to HTTP endpoints or renders pages. It will cause severe data and authentication token leakage between different users. | ||||||
|
|
||||||
| Instead, use `initializeServerApp` to create a lightweight, request-scoped Firebase app instance. | ||||||
|
|
||||||
| ```typescript | ||||||
| import { initializeServerApp } from "firebase/app"; | ||||||
|
|
||||||
| // Must be called for every incoming request handling routine | ||||||
| const app = initializeServerApp(firebaseConfig, { | ||||||
| authIdToken: extractedToken // Provided by framework-specific headers | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. initializeServerApp also takes an appCheckToken, does that need to be mentioned here, and how to use it? I guess it's in this official documentation page, do you expect the agent to already have found and read this page? https://firebase.google.com/docs/web/ssr-apps#:~:text=Use%20App%20Check%20in%20SSR%20environments,-App%20Check%20enforcement&text=The%20resulting%20token%20is%20then,initialization%20of%20the%20FirebaseServerApp%20instance. |
||||||
| }); | ||||||
| ``` | ||||||
|
|
||||||
| ### 2. Firestore Serialization Requirements | ||||||
|
|
||||||
| Data from `getFirestore` contains complex prototype objects (like `Timestamp`, `DocumentReference`, and `GeoPoint`) which cannot be natively serialized into JSON strings across network boundaries. | ||||||
|
|
||||||
| Always map over fetched Firestore documents to extract and convert these specific types to their serializable equivalents (such as `.toDate().toISOString()`) *before* returning them from the server component/loader. | ||||||
|
|
||||||
| ### 3. Data Connect Serialization Differences | ||||||
|
|
||||||
| Unlike Firestore, Firebase Data Connect utilizes standard GraphQL over its protocol. Responses to generated query functions are immediately returned as perfectly serializable JSON primitives. | ||||||
|
|
||||||
| Data fetched via Data Connect Server SDKs (`executeGraphql` or generated SDKs like `@firebasegen/default-connector`) does not require manual conversion of structures before being passed as page props or signals. | ||||||
|
|
||||||
| ### 4. Other Firebase Products (RTDB, Storage, Functions) | ||||||
|
|
||||||
| The `initializeServerApp` pattern is not limited to Firestore; you can safely initialize the client SDKs for Realtime Database, Cloud Storage, and Cloud Functions on the server. Because the app instance is authenticated via `authIdToken`, these calls will securely interact with Firebase infrastructure using the requesting user's identity. | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this supposed to say "auth"? I didn't see any description of how to use initializeServerApp with Firestore above.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, I don't think I follow. Do you mean that it should say:
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wasn't sure, that was my best guess at what you meant, since Firestore wasn't mentioned. |
||||||
| - **Realtime Database**: Data returned from `get(ref(db, 'path'))` is already primitively structured (JSON serializable). | ||||||
| - **Cloud Storage**: You can safely fetch download URLs utilizing `getDownloadURL(ref(storage, 'path'))` on the server. | ||||||
| - **Cloud Functions**: You can securely execute callable functions using `httpsCallable(functions, 'name')(data)` on the server on behalf of the user. | ||||||
|
|
||||||
| ### 5. Resuming Server Context in the Client | ||||||
|
|
||||||
| Once you have initialized the server app and fetched data, it is a best-practice to seamlessly "resume" this state in the CSR (Client-Side Rendering) environment without generating a layout shift or making redundant network requests: | ||||||
|
|
||||||
| - **Firebase Auth Hydration**: Instead of rendering a blank or unauthenticated state while `onAuthStateChanged` initializes the client Firebase Auth SDK, pass the parsed user data (obtained from the decoded session cookie) as an initial property (e.g. `initialUser` prop in React, or via `TransferState` in Angular) to your client-side Auth Provider. | ||||||
| - **Firestore `onSnapshotResume`**: In Firebase JS v10+, if you initiate a Firestore query on the server (using `getDocs()` or `getDoc()`), you can pass the `.toJSON()` representation of that snapshot to the client. The client can then call `onSnapshotResume(db, serializedSnapshot, ...)` to immediately resume the listener from the server's state, preventing the client from re-downloading the initial snapshot. | ||||||
| - **Data Connect `subscribe`**: When executing generated queries on the server (e.g., `listMovies()`), the returned result object exposes a `.toJSON()` function. By passing this serialized representation to the client, you can hydrate initial UI state and supply it directly into the generated `subscribe(serializedQuery, ...)` function to resume watching for cache updates without re-triggering the initial query. | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,321 @@ | ||
| # Angular SSR Firebase Reference | ||
|
|
||
| When building with Angular Universal/SSR, properly initializing Firebase per-request prevents cross-contamination. | ||
|
|
||
| ## 1. Initializing the Server App | ||
|
|
||
| Access the underlying Express `Request` object injected via the `REQUEST` token in Angular SSR. | ||
|
|
||
| ```typescript | ||
| import { Injectable, Inject, Optional } from '@angular/core'; | ||
| import { REQUEST } from '@nguniversal/express-engine/tokens'; | ||
|
maneesht marked this conversation as resolved.
Outdated
|
||
| import { Request } from 'express'; | ||
| import { initializeServerApp } from "firebase/app"; | ||
| import { getAuth } from "firebase/auth"; | ||
| import { getFirestore } from "firebase/firestore"; | ||
|
|
||
| @Injectable({ providedIn: 'root' }) | ||
| export class FirebaseSSRService { | ||
| constructor(@Optional() @Inject(REQUEST) private request: Request) {} | ||
|
|
||
| public initializeApp() { | ||
| const firebaseConfig = { | ||
| apiKey: "...", | ||
| authDomain: "...", | ||
| projectId: "...", | ||
| // ... | ||
| }; | ||
|
|
||
| // Extract custom Auth token securely from the injected HTTP request context | ||
| const authHeader = this.request?.headers['authorization']; | ||
| const authIdToken = authHeader ? authHeader.split('Bearer ')[1] : undefined; | ||
|
|
||
| const app = initializeServerApp(firebaseConfig, { | ||
| authIdToken: authIdToken, | ||
| releaseOnDeref: this.request | ||
| }); | ||
|
|
||
| return { | ||
| app, | ||
| auth: getAuth(app), | ||
| db: getFirestore(app) | ||
| }; | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## 2. Firestore Serialization | ||
|
|
||
| When bridging server state over to the client browser via Angular `TransferState` or Signals, complex instances (`Timestamp` and `GeoPoint`) must be mapped to primitive JS types. | ||
|
|
||
| ```typescript | ||
| // auth.service.ts | ||
| import { TransferState, makeStateKey } from '@angular/core'; | ||
|
|
||
| import { User } from 'firebase/auth'; | ||
| const USER_DATA_KEY = makeStateKey<User | null>('user_data_SSR'); | ||
|
maneesht marked this conversation as resolved.
Outdated
|
||
|
|
||
| // In your Server Service resolving FireStore data | ||
| const docSnap = await getDoc(doc(db, "users", "123")); | ||
| const data = docSnap.data(); | ||
|
|
||
| if (data) { | ||
| // Convert Firestore types to serialization-friendly primitives | ||
| const serializableData = { | ||
| ...data, | ||
| createdAt: data.createdAt?.toDate().toISOString(), | ||
| updatedAt: data.updatedAt?.toDate().toISOString(), | ||
| }; | ||
|
|
||
| // Safe to transfer! | ||
| this.transferState.set(USER_DATA_KEY, serializableData); | ||
| } | ||
| ``` | ||
|
|
||
| ## 3. Data Connect Serialization | ||
|
|
||
| Firebase Data Connect returns standard JSON responses via its GraphQL endpoints. You do not need to do any serialization mapping before pushing the responses into a Signal or transferring them via Angular `TransferState`. | ||
|
|
||
| ```typescript | ||
| import { listAllMenuItems } from '@firebasegen/default-connector'; | ||
| import { TransferState, makeStateKey, Injectable } from '@angular/core'; | ||
|
|
||
| interface MenuItem { id: string; title: string; url: string; } | ||
| const MENU_KEY = makeStateKey<MenuItem[]>('menu_ssr_state'); | ||
|
|
||
| @Injectable({ providedIn: 'root' }) | ||
| export class MenuService { | ||
| constructor(private transferState: TransferState) {} | ||
|
|
||
| async fetchMenuFromDataConnect() { | ||
| const response = await listAllMenuItems(); | ||
|
|
||
| // 'response.data.menuItems' is already primitive JSON! | ||
| this.transferState.set(MENU_KEY, response.data.menuItems); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## 4. Realtime Database (RTDB) | ||
|
|
||
| Realtime Database snapshots are natively JSON-serializable, allowing them to be pushed directly into standard Angular mechanisms like `TransferState` without type massaging. | ||
|
|
||
| ```typescript | ||
| import { getDatabase, ref, get } from "firebase/database"; | ||
| import { TransferState, makeStateKey, Injectable } from '@angular/core'; | ||
|
|
||
| interface ConfigState { featureFlag: boolean; version: string; } | ||
| const RTDB_KEY = makeStateKey<ConfigState | null>('rtdb_state'); | ||
|
|
||
| @Injectable({ providedIn: 'root' }) | ||
| export class RealtimeService { | ||
| constructor( | ||
| private transferState: TransferState, | ||
| private ssrContext: FirebaseSSRService // From Step 1 | ||
| ) {} | ||
|
|
||
| async fetchLeaderboard() { | ||
| const { app } = this.ssrContext.initializeApp(); | ||
| const db = getDatabase(app); | ||
|
|
||
| const snapshot = await get(ref(db, "leaderboard")); | ||
| const data = snapshot.val(); // Fully serializable JSON | ||
|
|
||
| this.transferState.set(RTDB_KEY, data); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## 5. Cloud Storage for Firebase | ||
|
|
||
| You can securely retrieve download URLs for Storage objects by passing the context-aware app to `getStorage`. | ||
|
|
||
| ```typescript | ||
| import { getStorage, ref, getDownloadURL } from "firebase/storage"; | ||
| import { TransferState, makeStateKey, Injectable } from '@angular/core'; | ||
|
|
||
| const AVATAR_KEY = makeStateKey<string>('avatar_url_state'); | ||
|
|
||
| @Injectable({ providedIn: 'root' }) | ||
| export class StorageService { | ||
| constructor( | ||
| private transferState: TransferState, | ||
| private ssrContext: FirebaseSSRService | ||
| ) {} | ||
|
|
||
| async fetchUserAvatar() { | ||
| const { app } = this.ssrContext.initializeApp(); | ||
| const storage = getStorage(app); | ||
|
|
||
| const fileRef = ref(storage, "users/me/avatar.png"); | ||
| const url = await getDownloadURL(fileRef); | ||
|
|
||
| this.transferState.set(AVATAR_KEY, url); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## 6. Cloud Functions | ||
|
|
||
| You can securely invoke Firebase HTTP Callable functions natively on the server on behalf of the requesting user. | ||
|
|
||
| ```typescript | ||
| import { getFunctions, httpsCallable } from "firebase/functions"; | ||
| import { TransferState, makeStateKey, Injectable } from '@angular/core'; | ||
|
|
||
| interface SubscriptionResult { id: string; status: string; } | ||
| const FUNC_RES_KEY = makeStateKey<SubscriptionResult | null>('func_res_state'); | ||
|
|
||
| @Injectable({ providedIn: 'root' }) | ||
| export class SubscriptionService { | ||
| constructor( | ||
| private transferState: TransferState, | ||
| private ssrContext: FirebaseSSRService | ||
| ) {} | ||
|
|
||
| async checkoutTier(plan: string) { | ||
| const { app } = this.ssrContext.initializeApp(); | ||
| const functions = getFunctions(app); | ||
|
|
||
| const createSubscription = httpsCallable(functions, 'createSubscription'); | ||
| const result = await createSubscription({ plan }); | ||
|
|
||
| this.transferState.set(FUNC_RES_KEY, result.data); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## 7. Resuming Server Context in the Client | ||
|
|
||
| To avoid client-side layout shifts and redundant network requests, resume the server-initialized Firebase state within your Angular components using `TransferState`. | ||
|
|
||
| ### Firebase Auth Hydration | ||
| Instead of rendering a blank loading screen while `onAuthStateChanged` resolves, evaluate the user from cookies on the server, store it in `TransferState`, and use it as the initial signal state on the client. | ||
|
|
||
| ```typescript | ||
| // auth.service.ts | ||
| import { Injectable, TransferState, makeStateKey, PLATFORM_ID, Inject, signal } from '@angular/core'; | ||
| import { isServer } from '@angular/common'; | ||
| import { getAuth, onAuthStateChanged, User } from 'firebase/auth'; | ||
| import { app } from './firebase.client'; // Standard initializeApp | ||
|
|
||
| const USER_STATE_KEY = makeStateKey<User | null>('auth_user_state'); | ||
|
|
||
| @Injectable({ providedIn: 'root' }) | ||
| export class AuthService { | ||
| // Initialize signal synchronously with the server-transferred state (if available) | ||
| readonly currentUser = signal<User | null>( | ||
| this.transferState.get(USER_STATE_KEY, null) | ||
| ); | ||
|
|
||
| constructor( | ||
| private transferState: TransferState, | ||
| @Inject(PLATFORM_ID) private platformId: Object | ||
| ) { | ||
| if (isServer(this.platformId)) { | ||
| // Logic running on server: Decode cookie/token and set TransferState | ||
| // (This usually happens in an APP_INITIALIZER or server resolver) | ||
| // this.transferState.set(USER_STATE_KEY, decodedUser); | ||
| } else { | ||
| // Logic running on client: Listen to actual SDK state changes | ||
| const auth = getAuth(app); | ||
| onAuthStateChanged(auth, (user) => { | ||
| this.currentUser.set(user); | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Firestore `onSnapshotResume` | ||
| To instantly render streaming Firestore data without a second network trip, use the Firebase JS SDK v10+ `onSnapshotResume` API. Extract the `.toJSON()` snapshot from the server, transfer it, and resume it. | ||
|
|
||
| ```typescript | ||
| // posts.service.ts | ||
| import { Injectable, TransferState, makeStateKey, PLATFORM_ID, Inject, signal } from '@angular/core'; | ||
| import { isServer } from '@angular/common'; | ||
| import { getFirestore, onSnapshotResume, collection, query, getDocs } from 'firebase/firestore'; | ||
| import { app } from './firebase.client'; | ||
| import { FirebaseSSRService } from './firebase-ssr.service'; | ||
|
|
||
| interface Post { id: string; title: string; content: string } | ||
| const SNAPSHOT_KEY = makeStateKey<Record<string, unknown>>('firestore_snapshot'); | ||
|
|
||
| @Injectable({ providedIn: 'root' }) | ||
| export class PostService { | ||
| readonly posts = signal<Post[]>([]); | ||
|
|
||
| constructor( | ||
| private transferState: TransferState, | ||
| @Inject(PLATFORM_ID) private platformId: Object, | ||
| private ssrContext: FirebaseSSRService | ||
| ) {} | ||
|
|
||
| async fetchAndListenPosts() { | ||
| if (isServer(this.platformId)) { | ||
| // Server: Fetch snapshot and serialize via .toJSON() | ||
| const { app } = this.ssrContext.initializeApp(); | ||
| const db = getFirestore(app); | ||
| const q = query(collection(db, "posts")); | ||
| const snapshot = await getDocs(q); | ||
|
|
||
| this.transferState.set(SNAPSHOT_KEY, snapshot.toJSON()); | ||
| } else { | ||
| // Client: Resume snapshot from TransferState | ||
| const serializedSnapshot = this.transferState.get(SNAPSHOT_KEY, null); | ||
| if (serializedSnapshot) { | ||
| const db = getFirestore(app); | ||
| onSnapshotResume(db, serializedSnapshot, (snapshot) => { | ||
| this.posts.set(snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }))); | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Data Connect `subscribe` Resumption | ||
| Firebase Data Connect's JS SDK uses a similar architecture to `onSnapshotResume` to hydrate client-side watches from server results. You can serialize the `.toJSON()` representation of the query result into `TransferState`, and pass it directly into the generated `subscribe` function. | ||
|
|
||
| ```typescript | ||
| // movies.service.ts | ||
| import { Injectable, TransferState, makeStateKey, PLATFORM_ID, Inject, signal } from '@angular/core'; | ||
| import { isServer } from '@angular/common'; | ||
| import { listMovies, ListMoviesData, ListMoviesVariables } from '@movie-app/dataconnect'; | ||
| import { subscribe, SerializedRef } from 'firebase/data-connect'; | ||
| import { FirebaseSSRService } from './firebase-ssr.service'; | ||
|
|
||
| const QUERY_KEY = makeStateKey<SerializedRef<ListMoviesData, ListMoviesVariables>>('dataconnect_query_snapshot'); | ||
|
|
||
| @Injectable({ providedIn: 'root' }) | ||
| export class MoviesService { | ||
| // Use the extracted raw data as your initial state for 0 layout shift | ||
| readonly movies = signal<ListMoviesData['movies']>( | ||
| this.transferState.get(QUERY_KEY, undefined)?.data?.movies ?? [] | ||
| ); | ||
|
|
||
| constructor( | ||
| private transferState: TransferState, | ||
| @Inject(PLATFORM_ID) private platformId: Object, | ||
| private ssrContext: FirebaseSSRService | ||
| ) {} | ||
|
|
||
| async fetchAndListenMovies() { | ||
| if (isServer(this.platformId)) { | ||
| // Server: Fetch QueryResult and serialize via .toJSON() | ||
| this.ssrContext.initializeApp(); | ||
| const result = await listMovies(); | ||
|
|
||
| this.transferState.set(QUERY_KEY, result.toJSON()); | ||
| } else { | ||
| // Client: Resume subscription from TransferState SerializedRef | ||
| const serializedQuery = this.transferState.get(QUERY_KEY, null); | ||
| if (serializedQuery) { | ||
| subscribe(serializedQuery, { | ||
| onNext: (res) => this.movies.set(res.data.movies) | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
Uh oh!
There was an error while loading. Please reload this page.