diff --git a/skills/firebase-ssr/SKILL.md b/skills/firebase-ssr/SKILL.md new file mode 100644 index 00000000..d570ef81 --- /dev/null +++ b/skills/firebase-ssr/SKILL.md @@ -0,0 +1,73 @@ +--- +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](./references/nextjs.md)` - For Next.js App Router (RSCs, Route Handlers). +- `[references/remix.md](./references/remix.md)` - For Remix (`loader` / `action` functions). +- `[references/angular-ssr.md](./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. You can pass both the user's Auth ID token and an App Check token (if enabled) to authenticate the server-side requests on behalf of the client. + +```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 + appCheckToken: extractedAppCheckToken // Optional, provided by framework-specific headers if App Check is enabled +}); +``` + +### 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. +- **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. diff --git a/skills/firebase-ssr/references/angular-ssr.md b/skills/firebase-ssr/references/angular-ssr.md new file mode 100644 index 00000000..b0907663 --- /dev/null +++ b/skills/firebase-ssr/references/angular-ssr.md @@ -0,0 +1,357 @@ +# 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 '@angular/ssr'; +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 and App Check token securely from the injected HTTP request context + const authHeader = this.request?.headers['authorization']; + const authIdToken = authHeader ? authHeader.split('Bearer ')[1] : undefined; + const appCheckToken = this.request?.headers['x-firebase-appcheck'] as string | undefined; + + const app = initializeServerApp(firebaseConfig, { + authIdToken: authIdToken, + appCheckToken: appCheckToken, + 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 +// user.service.ts +import { Injectable, TransferState, makeStateKey, PLATFORM_ID, Inject, signal } from '@angular/core'; +import { isServer } from '@angular/common'; +import { doc, getDoc, Firestore } from 'firebase/firestore'; + +interface UserData { id: string; name: string; createdAt: string; updatedAt: string; } +const USER_DATA_KEY = makeStateKey('user_data_SSR'); + +@Injectable({ providedIn: 'root' }) +export class UserService { + // Initialize signal synchronously with the server-transferred state (if available) + readonly userData = signal( + this.transferState.get(USER_DATA_KEY, null) + ); + + constructor( + private transferState: TransferState, + @Inject(PLATFORM_ID) private platformId: Object, + private db: Firestore // Assume provided or fetched via service + ) {} + + async fetchUser(userId: string) { + if (isServer(this.platformId)) { + // Server: Fetch data and serialize + const docSnap = await getDoc(doc(this.db, "users", userId)); + 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(), + } as UserData; + + // Safe to transfer! + this.transferState.set(USER_DATA_KEY, serializableData); + this.userData.set(serializableData); + } + } else { + // Client: If not found in TransferState (e.g., direct client navigation), fetch it + if (!this.userData()) { + const docSnap = await getDoc(doc(this.db, "users", userId)); + const data = docSnap.data(); + if (data) { + this.userData.set({ + ...data, + createdAt: data.createdAt?.toDate().toISOString(), + updatedAt: data.updatedAt?.toDate().toISOString(), + } as UserData); + } + } + } + } +} +``` + +## 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('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('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('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('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('auth_user_state'); + +@Injectable({ providedIn: 'root' }) +export class AuthService { + // Initialize signal synchronously with the server-transferred state (if available) + readonly currentUser = signal( + 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>('firestore_snapshot'); + +@Injectable({ providedIn: 'root' }) +export class PostService { + readonly posts = signal([]); + + 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>('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( + 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) + }); + } + } + } +} +``` diff --git a/skills/firebase-ssr/references/nextjs.md b/skills/firebase-ssr/references/nextjs.md new file mode 100644 index 00000000..371ac0f9 --- /dev/null +++ b/skills/firebase-ssr/references/nextjs.md @@ -0,0 +1,264 @@ +# Next.js (App Router) Firebase SSR Reference + +When building with Next.js App Router, utilize React Server Components (RSCs) alongside `initializeServerApp` to securely extract and pass data. + +## Cache Components +Currently, cached components are not supported. If the user is using Next.js v16, ensure that the `cacheComponents` is set to `false` in `next.config.ts`. + +## 1. Initializing the Server App + +Use the standard Next.js `headers()` or `cookies()` APIs to retrieve tokens for Firebase Auth without accessing the Express request object. + +```tsx +import { initializeServerApp } from "firebase/app"; +import { getAuth } from "firebase/auth"; +import { getFirestore } from "firebase/firestore"; +import { headers } from "next/headers"; + +export async function setupFirebaseSSR() { + const firebaseConfig = { + apiKey: "...", + authDomain: "...", + projectId: "...", + // ... + }; + + // Extract custom Auth token and App Check token using modern Next.js headers() + const reqHeaders = await headers(); + const authIdToken = reqHeaders.get('authorization')?.split('Bearer ')[1]; + const appCheckToken = reqHeaders.get('x-firebase-appcheck'); + + const app = initializeServerApp(firebaseConfig, { + authIdToken: authIdToken, + appCheckToken: appCheckToken, + releaseOnDeref: reqHeaders + }); + + return { + app, + auth: getAuth(app), + db: getFirestore(app) + }; +} +``` + +## 2. Firestore Serialization + +When calling `getFirestore` from the Server and passing data into Client Components (`"use client"`), props must be serializable JSON. `Timestamp` or `GeoPoint` objects will throw an error during the Next.js build or SSR phase if they are not explicitly serialized. + +```tsx +// app/page.tsx (Server Component) +import { doc, getDoc } from "firebase/firestore"; +import ClientComponent from './ClientComponent'; + +export default async function Page() { + const { db } = await setupFirebaseSSR(); + const docSnap = await getDoc(doc(db, "users", "123")); + const data = docSnap.data(); + + if (!data) return null; + + // Convert Firestore types to standard JS primitives + const serializableData = { + ...data, + createdAt: data.createdAt?.toDate().toISOString(), + updatedAt: data.updatedAt?.toDate().toISOString(), + }; + + return ; +} +``` + +## 3. Data Connect Serialization + +Firebase Data Connect returns standard JSON responses via its GraphQL endpoints. You do not need to do any mapping or serialization before passing responses as props in Server Components. + +```tsx +// app/menu/page.tsx (Server Component) +import { listAllMenuItems } from '@firebasegen/default-connector'; +import { MenuList } from './client-components/MenuList'; + +export default async function MenuPage() { + const response = await listAllMenuItems(); + + // 'response.data.menuItems' is already primitive JSON! + return ( +
+

Menu

+ +
+ ); +} +``` + +## 4. Realtime Database (RTDB) + +Realtime Database snapshots are natively JSON-serializable, making them straightforward to pass to Client Components without complex mappings. + +```tsx +// app/rtdb/page.tsx (Server Component) +import { getDatabase, ref, get } from "firebase/database"; +import ClientComponent from './ClientComponent'; + +export default async function RealtimeDataPage() { + const { app } = await setupFirebaseSSR(); + const db = getDatabase(app); + + const snapshot = await get(ref(db, "leaderboard")); + const data = snapshot.val(); // Fully serializable JSON + + return ; +} +``` + +## 5. Cloud Storage for Firebase + +You can securely retrieve download URLs for Storage objects on the server leveraging the user's isolated context. + +```tsx +// app/storage/page.tsx (Server Component) +import { getStorage, ref, getDownloadURL } from "firebase/storage"; +import Image from "next/image"; + +export default async function StorageImagePage() { + const { app } = await setupFirebaseSSR(); + const storage = getStorage(app); + + const fileRef = ref(storage, "users/me/avatar.png"); + const url = await getDownloadURL(fileRef); + + return User Avatar; +} +``` + +## 6. Cloud Functions + +You can securely invoke Firebase HTTP Callable functions natively on the server before rendering the UI. + +```tsx +// app/functions/page.tsx (Server Component) +import { getFunctions, httpsCallable } from "firebase/functions"; +import ClientComponent from './ClientComponent'; + +export default async function FunctionsPage() { + const { app } = await setupFirebaseSSR(); + const functions = getFunctions(app); + + const createSubscription = httpsCallable(functions, 'createSubscription'); + const result = await createSubscription({ plan: "premium" }); + + return ; +} +``` + +## 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 Next.js Client Components. + +### Firebase Auth Hydration +Instead of rendering a blank loading screen while `onAuthStateChanged` resolves, pass the decoded user state (from your session cookie or `headers()`) down as a prop to your React context. + +```tsx +// app/ClientAuthProvider.tsx (Client Component) +"use client"; +import { useEffect, useState } from "react"; +import { getAuth, onAuthStateChanged, User } from "firebase/auth"; +import { app } from "./firebaseClient"; // standard initializeApp setup + +export function ClientAuthProvider({ initialUser, children }: { initialUser: User | null, children: React.ReactNode }) { + const [user, setUser] = useState(initialUser); + + useEffect(() => { + const auth = getAuth(app); + return onAuthStateChanged(auth, setUser); + }, []); + + return {children}; +} +``` + +### Firestore `onSnapshotResume` +To instantly render streaming Firestore data without a second network trip, use the Firebase JS SDK v10+ `onSnapshotResume` API. + +```tsx +// app/page.tsx (Server Component) +import { getFirestore, collection, getDocs, query } from "firebase/firestore"; + +export default async function Page() { + const { app } = await setupFirebaseSSR(); + const db = getFirestore(app); + + const q = query(collection(db, "posts")); + const snapshot = await getDocs(q); + + // Pass the internal .toJSON() representation of the snapshot to the client + return ; +} +``` + +```tsx +// app/PostList.tsx (Client Component) +"use client"; +import { useEffect, useState } from "react"; +import { getFirestore, onSnapshotResume } from "firebase/firestore"; +import { app } from "./firebaseClient"; + +export default function PostList({ serializedSnapshot }: { serializedSnapshot: object }) { + const [docs, setDocs] = useState([]); + + useEffect(() => { + const db = getFirestore(app); + + // Resume the listener from the server's snapshot state seamlessly + const unsubscribe = onSnapshotResume(db, serializedSnapshot, (snapshot) => { + setDocs(snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }))); + }); + + return unsubscribe; + }, [serializedSnapshot]); + + return
{docs.map(d =>
{d.title}
)}
; +} +``` + +### 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 pass the serialized representation of the query result directly into the generated `subscribe` function. + +```tsx +// app/movies/page.tsx (Server Component) +import { listMovies } from '@movie-app/dataconnect'; + +export default async function MoviesPage() { + await setupFirebaseSSR(); // Request-isolated app injection + + const result = await listMovies(); // Internal QueryResult object + + // Pass the internal .toJSON() SerializedRef representation to the client + return ; +} +``` + +```tsx +// app/movies/MoviesList.tsx (Client Component) +"use client"; +import { useEffect, useState } from "react"; +import { subscribe, SerializedRef } from 'firebase/data-connect'; +import { ListMoviesData, ListMoviesVariables } from '@movie-app/dataconnect'; + +export default function MoviesList({ serializedQuery }: { serializedQuery: SerializedRef }) { + // Use the extracted raw data as your initial state for 0 layout shift + const [movies, setMovies] = useState(serializedQuery.data.movies); + + useEffect(() => { + // Resume the subscription utilizing the serialized reference state + // Without recreating the query context from scratch + const unsubscribe = subscribe(serializedQuery, { + onNext: (res) => setMovies(res.data.movies) + }); + + return unsubscribe; + }, [serializedQuery]); + + return
{movies.map(m =>
{m.title}
)}
; +} +``` diff --git a/skills/firebase-ssr/references/remix.md b/skills/firebase-ssr/references/remix.md new file mode 100644 index 00000000..04b3bbd5 --- /dev/null +++ b/skills/firebase-ssr/references/remix.md @@ -0,0 +1,283 @@ +# Remix Firebase SSR Reference + +When building with Remix, leverage `loader` and `action` functions to securely initialize Firebase contextually on the server. + +## 1. Initializing the Server App + +Extract custom tokens or session IDs using the `request` argument injected automatically by Remix. + +```tsx +import { initializeServerApp } from "firebase/app"; +import { getAuth } from "firebase/auth"; +import { getFirestore } from "firebase/firestore"; + +export async function setupFirebaseSSR(request: Request) { + const firebaseConfig = { + apiKey: "...", + authDomain: "...", + projectId: "...", + // ... + }; + + // Extract custom Auth token and App Check token using standard Fetch API Headers + const authHeader = request.headers.get('Authorization'); + const authIdToken = authHeader?.split('Bearer ')[1]; + const appCheckToken = request.headers.get('x-firebase-appcheck'); + + const app = initializeServerApp(firebaseConfig, { + authIdToken: authIdToken, + appCheckToken: appCheckToken, + releaseOnDeref: request + }); + + return { + app, + auth: getAuth(app), + db: getFirestore(app) + }; +} +``` + +## 2. Firestore Serialization + +When using standard `json()` loader responses to export data to your Remix Route Components, remember that `Timestamp` is not JSON-serializable. + +```tsx +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import { doc, getDoc } from "firebase/firestore"; +import { useLoaderData } from "@remix-run/react"; + +export async function loader({ request }: LoaderFunctionArgs) { + const { db } = await setupFirebaseSSR(request); + const docSnap = await getDoc(doc(db, "posts", "some_id")); + const data = docSnap.data(); + + if (!data) throw new Response("Not Found", { status: 404 }); + + // Map out non-serializable fields + const post = { + ...data, + createdAt: data.createdAt?.toDate().toISOString(), + updatedAt: data.updatedAt?.toDate().toISOString(), + }; + + return json({ post }); +} + +export default function PostRoute() { + const { post } = useLoaderData(); + + return ( +
+

{post.title}

+

Created on: {new Date(post.createdAt).toLocaleDateString()}

+
+ ); +} +``` + +## 3. Data Connect Serialization + +Firebase Data Connect returns standard JSON responses via its GraphQL endpoints. You do not need to serialize `timestamps` when calling Data Connect generated functions. + +```tsx +import { json } from "@remix-run/node"; +import { useLoaderData } from "@remix-run/react"; +import { listAllMenuItems } from '@firebasegen/default-connector'; + +export async function loader() { + const response = await listAllMenuItems(); + + // Data connect responses are inherently serializable! + return json({ menuItems: response.data.menuItems }); +} + +export default function MenuRoute() { + const { menuItems } = useLoaderData(); + + return ( +
    + {menuItems.map(item =>
  • {item.name}
  • )} +
+ ); +} +``` + +## 4. Realtime Database (RTDB) + +Realtime Database snapshots are natively JSON-serializable, allowing them to be returned directly inside Remix `json()` loaders. + +```tsx +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import { getDatabase, ref, get } from "firebase/database"; +import { useLoaderData } from "@remix-run/react"; + +export async function loader({ request }: LoaderFunctionArgs) { + const { app } = await setupFirebaseSSR(request); + const db = getDatabase(app); + + const snapshot = await get(ref(db, "leaderboard")); + const data = snapshot.val(); // Fully serializable JSON + + return json({ leaderboardData: data }); +} + +export default function RealtimeDataRoute() { + const { leaderboardData } = useLoaderData(); + + return
{JSON.stringify(leaderboardData, null, 2)}
; +} +``` + +## 5. Cloud Storage for Firebase + +You can securely retrieve download URLs for Storage objects within a loader before the page renders to the user. + +```tsx +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import { getStorage, ref, getDownloadURL } from "firebase/storage"; +import { useLoaderData } from "@remix-run/react"; + +export async function loader({ request }: LoaderFunctionArgs) { + const { app } = await setupFirebaseSSR(request); + const storage = getStorage(app); + + const fileRef = ref(storage, "users/me/avatar.png"); + const url = await getDownloadURL(fileRef); + + return json({ avatarUrl: url }); +} + +export default function StorageRoute() { + const { avatarUrl } = useLoaderData(); + + return User Avatar; +} +``` + +## 6. Cloud Functions + +You can securely invoke Firebase HTTP Callable functions natively on the server during an `action` or `loader`. + +```tsx +import { json, type ActionFunctionArgs } from "@remix-run/node"; +import { getFunctions, httpsCallable } from "firebase/functions"; + +export async function action({ request }: ActionFunctionArgs) { + const { app } = await setupFirebaseSSR(request); + const functions = getFunctions(app); + + const formData = await request.formData(); + const plan = formData.get("plan"); + + const createSubscription = httpsCallable(functions, 'createSubscription'); + const result = await createSubscription({ plan }); + + return json({ subscriptionResult: 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 Remix components. + +### Firebase Auth Hydration +Instead of rendering a blank loading screen while `onAuthStateChanged` resolves, pass the decoded user state (from your session cookie inside the `loader`) down via `useLoaderData` to initialize your React context. + +```tsx +import { useEffect, useState } from "react"; +import { useLoaderData } from "@remix-run/react"; +import { getAuth, onAuthStateChanged, User } from "firebase/auth"; +import { app } from "~/firebaseClient"; // standard initializeApp setup + +export default function Root() { + // initialUser is extracted from the session cookie in your root loader + const { initialUser } = useLoaderData(); + const [user, setUser] = useState(initialUser); + + useEffect(() => { + const auth = getAuth(app); + return onAuthStateChanged(auth, setUser); + }, []); + + return ; +} +``` + +### Firestore `onSnapshotResume` +To instantly render streaming Firestore data without a second network trip, use the Firebase JS SDK v10+ `onSnapshotResume` API. Return the internal `.toJSON()` representation from your `loader`. + +```tsx +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import { useLoaderData } from "@remix-run/react"; +import { getFirestore, collection, getDocs, query, onSnapshotResume } from "firebase/firestore"; +import { useEffect, useState } from "react"; +import { app } from "~/firebaseClient"; + +export async function loader({ request }: LoaderFunctionArgs) { + const { app } = await setupFirebaseSSR(request); + const db = getFirestore(app); + + const q = query(collection(db, "posts")); + const snapshot = await getDocs(q); + + // Pass the internal .toJSON() representation of the snapshot to the client + return json({ serializedSnapshot: snapshot.toJSON() }); +} + +export default function PostRoute() { + const { serializedSnapshot } = useLoaderData(); + const [docs, setDocs] = useState([]); + + useEffect(() => { + const db = getFirestore(app); + + // Resume the listener from the server's snapshot state seamlessly + const unsubscribe = onSnapshotResume(db, serializedSnapshot, (snapshot) => { + setDocs(snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }))); + }); + + return unsubscribe; + }, [serializedSnapshot]); + + return
{docs.map(d =>
{d.title}
)}
; +} +``` + +### 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 return the `.toJSON()` serialized representation of the query result from your loader, and pass it directly into the generated `subscribe` function. + +```tsx +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import { useLoaderData } from "@remix-run/react"; +import { useEffect, useState } from "react"; +import { subscribe, SerializedRef } from 'firebase/data-connect'; +import { listMovies, ListMoviesData, ListMoviesVariables } from '@movie-app/dataconnect'; + +export async function loader({ request }: LoaderFunctionArgs) { + await setupFirebaseSSR(request); + + const result = await listMovies(); + + // Pass the internal .toJSON() SerializedRef representation to the client + return json({ serializedQuery: result.toJSON() }); +} + +export default function MoviesRoute() { + const { serializedQuery } = useLoaderData() as { serializedQuery: SerializedRef }; + + // Use the extracted raw data as your initial state for 0 layout shift + const [movies, setMovies] = useState(serializedQuery.data.movies); + + useEffect(() => { + // Resume the subscription utilizing the serialized reference state + const unsubscribe = subscribe(serializedQuery, { + onNext: (res) => setMovies(res.data.movies) + }); + + return unsubscribe; + }, [serializedQuery]); + + return
{movies.map(m =>
{m.title}
)}
; +} +```