Firebase Auth та Firestore у Next.js без плоских колекцій
Захищайте один екземпляр додатку Firebase, дозвольте інструментам автентифікації кидати типовані помилки, розміщуйте тренування під кожним користувачем та перевіряйте записи за допомогою емуляторів.
Стартери на базі Firebase + Next.js, успадковані від інших проектів, часто мають дві проблеми: одну колекцію workouts, де зберігаються всі документи кожного облікового запису, та блоки обробки помилок, які фіксують їх та повертають undefined, через що користувачі ніколи не дізнаються про невдалий запис. Демонстрації з одним способом входу приховують обидві проблеми. Однак реальний трафік їх не приховує.
Основні компоненти системи автентифікації та відстеження в додатку для фітнесу були перебудовані на основі Firebase JS SDK 12.17.1 та протестовані на емуляторах Firebase, щоб можна було перевірити процес запису від початку до кінця. Нижченаведені примітки присвятовані якнайранішому виправленню структури.
Одна інстанція Firebase, а не п’ять
Частою проблемою є виклик функції initializeApp у верхній частині модуля, який імпортується кількома маршрутами. Функція hot reload у Next.js разом із розділенням бандлів серверної та клієнтської частин може завантажувати цей модуль двічі; під час другого виклику з’являється повідомлення про те, що значення app вже існує. Щоб уникнути цього, потрібно захистити виклик функції.
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(...) — це весь механізм захисту. Розміщуйте конфігурацію у змінних середовища NEXT_PUBLIC_ замість жорсткого кодування — не тому, що конфігурація Firebase для вебу є секретною (вона за своєю суттю надсилається до браузера), а тому, що окремі проекти для розробки та продакшну не повинні вимагати зміни коду для перемикання між ними.
У старіших посібниках у файл вставляють рядки "your-api-key". Це саме по собі не є вразливістю; це звичка, яка зрештою призводить до того, що справжня конфігурація продакшну потрапляє у публічний репозиторій.
Аутентифікація, яка повертає щось, що може використовуватися користувачем
Використовуйте саме таку структуру. Зверніть увагу, що вона не робить: не ловить помилки та не повертає 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;
}
Старіші версії допоміжних функцій обгортають виклик у try/catch, виводять повідомлення про помилку реєстрації та повертають undefined. Код користувацького інтерфейсу потім чекає на registerUser(...) та намагається отримати доступ до user.uid, можливо, з неправильним значенням — тому неправильний пароль чи дублікат електронної пошти ніколи не стають чіткою помилкою автентифікації; це проявляється пізніше у спробі зчитати властивість від undefined, що знаходиться далеко від справжньої проблеми.
Краще використовувати механізм відхилення. Виклик автентифікації у Firebase генерує конкретні помилки (auth/email-already-in-use, auth/weak-password та подібні). Перетворюйте ці коди на зрозумілі повідомлення у формі. Нехай допоміжні функції працюють чесно: або успішно виконують завдання, або кидають помилку.
Структуруйте тренування за користувачами з самого початку
Ця зміна має найбільше значення поза рамками прототипу. Старі приклади створюють однорівневу колекцію workouts із полем userId:
// what everyone copies — one collection for the whole app
addDoc(collection(db, "workouts"), { userId, ...workout });
Запит “my workouts” сканує колекцію, яка розширюється разом із усією аудиторією користувачів, і правила безпеки мають фільтрувати дані за параметром userId для кожної операції. Краще використовувати підколекцію, щоб тренування кожного користувача знаходилися у власному документі:
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“) вказує на приватну підколекцію цього облікового запису. Тоді правила безпеки можуть порівнювати request.auth.uid із сегментом шляху {uid} як під час читання, так і під час запису, щоб кожен запит залишався в межах документів конкретного користувача.
Два моменти, які варто підкреслити. Краще використовувати serverTimestamp() замість new Date(): клієнтські годинники (або зловмисні клієнти) записують некоректні часові позначки; serverTimestamp() — це спеціальний показник, який Firestore заповнює часом сервера під час збереження даних, і його неможливо підробити. Використання типу даних WorkoutInput замість any дозволяє виявити помилки у назвах полів, які інакше проявлятимуться лише тоді, коли графік мовчки не відобразить жодних даних.
Доведіть, що дані справді записуються
Не довіряйте коду Firebase, який ніколи не виконувався на емуляторі — SDK може приймати запити, які справжні правила безпеки відхилити. Налаштуйте SDK на локальні емулятори та пройдіть повний цикл: реєстрацію, запис даних та їх повторне читання.
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"));
Запуск тієї самої операції через емуляторний запускач дав конкретний uid автентифікації, ідентифікатор запису та документ для зчитування, у якого значення createdAt було справжнім часовим позначенням Firestore (seconds/nanoseconds), а не значенням-сигналом — що є доказом того, що сервер заповнив це поле. Неправильні шляхи або типи полів призводять до помилок на ноутбуці, а не після розгортання.
Увага щодо інструментів: Firebase CLI тепер вимагає Java 21 або новішої версії. Старіша JRE на чистому ноутбуці зупинить емуляторний запускач через помилку версії ще до того, як буде виконаний будь-який код додатку. Оновіть JDK, а потім спробуйте ще раз.
Куди рухатися далі
Аутентифікація разом із правильно налаштованим записом є основою. Готовий продукт зазвичай підключає функцію onAuthStateChanged для керування станом інтерфейсу під час автентифікації, мапує коди помилок аутентифікації у формі та відображає історію за допомогою getDocs, orderBy("createdAt", "desc") та параметра limit. Усі ці функції все ще залежать від двох ранніх рішень: ініціалізації Firebase один раз за захисним механізмом та розміщення документів тренувань під користувачем, який є власником. Якщо пропустити хоча б одне з цих рішень, подальші функції постраждають від наслідків.