Firebase Auth i Firestore w Next.js bez kolekcji płaskich
Zachowaj jedną instancję aplikacji Firebase, pozwól narzędziom autoryzacji rzucać typowane błędy, umieść treningi wewnątrz każdego użytkownika oraz zweryfikuj operacje zapisu za pomocą emulatorów.
Projekty startowe oparte na Firebase i Next.js często zawierają dwa poważne problemy: po pierwsze, kolekcja workouts przechowująca wszystkie dokumenty kont, oraz bloki obsługi błędów, które zapisują informacje o błędzie, a następnie zwracają undefined, dzięki czemu użytkownicy nie dowiadują się o nieudanej operacji zapisu. Dema z jednym procesem logowania ukrywają oba te problemy, natomiast ruch w środowisku produkcyjnym ich nie ukrywa.
Jądro aplikacji fitness zajmujące się autoryzacją i śledzeniem zostało przeprojektowane przy użyciu Firebase JS SDK 12.17.1 i przetestowane na emulatorach Firebase, aby móc sprawdzić proces zapisu od początku do końca. Poniższe notatki koncentrują się na wczesnym naprawianiu struktury aplikacji.
Jedna instancja Firebase, a nie pięć
Częstym błędem jest wywoływanie funkcji initializeApp na początku modułu, który jest importowany przez kilka tras. Funkcja hot reload w Next.js w połączeniu z podziałem plików na serwerowe i klienckie może spowodować dwukrotne załadowanie tego modułu; drugie wywołanie powoduje komunikat o tym, że domyślna aplikacja już istnieje. Należy to zapobiec:
import { initializeApp, getApps, getApp } from "firebase/app";
import { getAuth } from "firebase/auth";
import { getFirestore } from "firebase/firestore";
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN!,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET!,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_SENDER_ID!,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID!,
};const app = getApps().length ? getApp() : initializeApp(firebaseConfig);export const auth = getAuth(app);
export const db = getFirestore(app);
getApps().length ? getApp() : initializeApp(...) to cała ochrona. Umieść konfigurację w zmiennych środowiskowych NEXT_PUBLIC_ zamiast wpisywać ją bezpośrednio — nie dlatego, że konfiguracja Firebase dla strony internetowej jest tajna (została zaprojektowana tak, by trafić do przeglądarki), ale dlatego, że oddzielne projekty do rozwoju i produkcji nie powinny wymagać edycji kodu źródłowego, aby je przełączyć.
Stare tutoriale wklejają do pliku ciągi tekstowe "your-api-key". Sam w sobie nie stanowi to luki bezpieczeństwa; jest to nawyk, który ostatecznie skutkuje umieszczeniem prawdziwej konfiguracji produkcyjnej w publicznym repozytorium.
Autoryzacja, która zwraca coś, co może być wykorzystane przez użytkownika
Wykorzystaj tę strukturę. Zwróć uwagę na to, czego unika: łapania błędu i zwracania wartości undefined.
import {
createUserWithEmailAndPassword,
type User,
} from "firebase/auth";
import { addDoc, collection, serverTimestamp } from "firebase/firestore";
import { auth, db } from "./firebase";
export async function registerUser(
email: string,
password: string,
): Promise<User> {
const cred = await createUserWithEmailAndPassword(auth, email, password);
return cred.user;
}
Starsze wersje narzędzi otaczają tę operację blokiem try/catch, wyświetlają błąd rejestracji i zwracają wartość undefined. Kod interfejsu użytkownika następnie czeka na wywołanie registerUser(...) i próbuje uzyskać dostęp do user.uid, mogąc napotkać brakującą wartość — w rezultacie błędny hasło lub duplikat adresu e-mail nigdy nie jest traktowany jako prawdziwy błąd autoryzacji; pojawia się później jako próba odczytu właściwości z undefined, daleko od rzeczywistego problemu.
Lepiej jest natychmiast odrzucić próbę. Wywołania Firebase Auth generują sprecyzowane błędy (auth/email-already-in-use, auth/weak-password i podobne). Przyporządkuj te kody do zrozumiałych komunikatów w formularzu. Niech narzędzie do obsługi danych działa uczciwie: albo odniesie sukces, albo rzuci błąd.
Strukturyzuj ćwiczenia według użytkowników od samego początku
Ta zmiana ma największe znaczenie poza fazą prototypowania. Stare przykłady tworzą prosty zbiór workouts z polem userId:
// what everyone copies — one collection for the whole app
addDoc(collection(db, "workouts"), { userId, ...workout });
Zapytanie „moje treningi” skanuje kolekcję, która rośnie wraz z całym gronem użytkowników, dlatego reguły bezpieczeństwa muszą filtrować dane na podstawie userId przy każdej operacji. Lepiej użyć podkolekcji, aby treningi każdego użytkownika znajdowały się w jego własnym dokumencie:
export type WorkoutInput = {
type: string;
durationMinutes: number;
caloriesBurned: number;
};
export async function addWorkoutSession(
userId: string,
workout: WorkoutInput,
): Promise<string> {
const ref = await addDoc(collection(db, "users", userId, "workouts"), {
...workout,
createdAt: serverTimestamp(),
});
return ref.id;
}
collection(db, „users”, userId, „workouts”) odnosi się do prywatnej podkolekcji danego konta. Reguły bezpieczeństwa mogą wtedy porównywać request.auth.uid z segmentem ścieżki {uid} zarówno przy odczytach, jak i zapisach, dzięki czemu każde zapytanie pozostaje w ramach dokumentów danego użytkownika.
Dwa szczegóły warte podkreślenia. Wolimy serverTimestamp() zamiast new Date(): zegary klienta (lub złośliwi klienci) mogą wpisywać błędne daty; serverTimestamp() to specjalny marker, który Firestore wypełnia czasem serwera przy zapisie i nie można go sfałszować. Użycie typu WorkoutInput zamiast any pozwala wykryć błędy w nazwach pól, które w przeciwnym razie ujawniłyby się dopiero wtedy, gdy wykres cicho nic nie wyświetli.
Dowód faktycznego zapisu
Nie ufaj kodowi Firebase, który nigdy nie został przetestowany na emulatorze — SDK może przyjąć żądania, które prawdziwa zasada bezpieczeństwa odrzuciłaby. Skieruj SDK na lokalne emulatory i przetestuj cały proces: rejestrację, zapis oraz odczyt danych.
import { getAuth, connectAuthEmulator, createUserWithEmailAndPassword } from "firebase/auth";
import { getFirestore, connectFirestoreEmulator, addDoc, getDocs, collection, serverTimestamp } from "firebase/firestore";
connectAuthEmulator(auth, "http://127.0.0.1:9099", { disableWarnings: true });
connectFirestoreEmulator(db, "127.0.0.1", 8080);const cred = await createUserWithEmailAndPassword(auth, email, "s3cret-pass");
const ref = await addDoc(
collection(db, "users", cred.user.uid, "workouts"),
{ type: "run", durationMinutes: 32, caloriesBurned: 410, createdAt: serverTimestamp() },
);
const snap = await getDocs(collection(db, "users", cred.user.uid, "workouts"));
Uruchomienie tego samego ścieżki za pomocą emulator runnera dało konkretny identyfikator autoryzacji, identyfikator zapisu oraz dokument odczytany z powrotem, którego pole createdAt zawierało rzeczywisty zapis czasu z Firestore (seconds/nanoseconds), a nie wartość domyślną — co jest dowodem na to, że serwer wypełnił to pole. Błędne ścieżki lub typy pól powodują błędy na laptopie, a nie po rozruchu aplikacji.
Uwaga dotycząca narzędzi: Firebase CLI wymaga teraz Java 21 lub nowszej wersji. Starszy JRE na czystym laptopie spowoduje błąd wersji i zatrzyma emulator runnera, zanim uruchomiony zostanie jakikolwiek kod aplikacji. Należy zaktualizować JDK, a następnie spróbować ponownie.
Kolejne kroki
Bazą jest autoryzacja w połączeniu z poprawnie skonfigurowanym prawem do zapisu. Gotowy produkt zazwyczaj używa funkcji onAuthStateChanged do zarządzania stanem interfejsu po zalogowaniu, mapuje kody błędów autoryzacji oraz przedstawia historię użycia za pomocą getDocs, orderBy("createdAt", "desc") i parametru limit. Wszystkie te funkcjonalności wciąż opierają się na dwóch wcześniejszych decyzjach: inicjalizacji Firebase raz za zabezpieczeniem oraz umieszczaniu dokumentów treningowych pod odpowiednim użytkownikiem. Jeśli któraś z tych decyzji zostanie pominęta, późniejsze funkcjonalności będą cierpieć z tego powodu.