От localStorage Next.js к cloud favorites, Supabase Auth, RLS и миграция без потери гостевого UX

Избранное можно поместить в localStorage. Пользователь нажимает кнопку, приложение сохраняет ID записи в браузере, отдельный backend для этого не нужен. Проблема появляется после добавления аккаунтов. localStorage привязан к конкретному браузеру. После входа с другого устройства сохранённого списка там нет. Простая замена localStorage на облачную таблицу тоже меняет поведение продукта, теперь для кнопки «В избранное» приходится сначала требовать авторизацию. Если гостевой режим уже работает, логичнее сохранить его и переключать источник данных после входа.
В EventMap схема выглядит так:

guest ↓localStorageauthenticated user ↓Supabase Auth ↓public.favorites ↓RLSfirst sign in ↓copy local favorites to cloud

Сначала определяем два режима

Для интерфейса избранное остаётся одной функцией. Источник данных зависит от состояния авторизации. Для гостя:

FavoriteButton ↓useFavoritesCloud ↓useFavorites ↓localStorage

Для пользователя:

FavoriteButton ↓useFavoritesCloud ↓Supabase browser client ↓public.favorites

Компонент кнопки не должен знать, куда именно записывается событие. В проекте он использует один hook:

const { isFavorite, isHydrated, removeFavorite, toggleFavoriteEvent,} = useFavoritesCloud();

Переключение между browser storage и Supabase находится ниже этого уровня.

Что остаётся в localStorage

Гостевой режим существовал до подключения Supabase. Для него используются два ключа:

export const FAVORITES_STORAGE_KEY = "eventmap:favorites";export const FAVORITE_EVENTS_STORAGE_KEY = "eventmap:favorite-events";

Первый хранит IDs:

[ "london-music-night", "berlin-jazz-evening"]

Второй хранит snapshots карточек:

type FavoriteEventsMap = Record;

Snapshot нужен странице избранного. Она может показать сохранённую карточку без повторного запроса каждого события сразу после чтения localStorage.
Данные из браузера не используются без проверки. IDs проходят через нормализацию:

export function normaliseFavoriteIds( value: unknown): string[] { if (!Array.isArray(value)) { return []; } const ids = value .filter( (item): item is string => typeof item === "string" ) .map((item) => item.trim()) .filter(Boolean); return Array.from(new Set(ids));}

Для snapshots отдельно проверяется форма EventCardData.
Это не полноценная runtime schema вроде Zod, но повреждённый JSON или объект неожиданной формы не становится карточкой автоматически.

Почему hydration здесь отдельная часть состояния

localStorage существует только в браузере. Поэтому клиентский hook сначала должен понять, прочитаны ли данные:

let isStoreHydrated = false;

После чтения storage:

function readStoreFromBrowser() { favoriteIdsStore = parseFavoriteIds( window.localStorage.getItem( FAVORITES_STORAGE_KEY ) ); favoriteEventsStore = parseFavoriteEvents( window.localStorage.getItem( FAVORITE_EVENTS_STORAGE_KEY ) ); isStoreHydrated = true; notifyListeners();}

Пока этот этап не закончен, кнопка не должна считать пустой массив реальным состоянием избранного. Та же граница потом используется при подключении облачного режима. Сначала гидратируется локальный store, затем определяется текущий пользователь.

Supabase client для браузера и сервера

В проекте используются @supabase/supabase-js и @supabase/ssr. Конфигурация состоит из двух публичных переменных:

NEXT_PUBLIC_SUPABASE_URL=NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=

Browser client создаётся так:

import { createBrowserClient } from "@supabase/ssr";export function createSupabaseBrowserClient() { const config = getSupabaseConfig(); if (!config) { return null; } return createBrowserClient( config.url, config.publishableKey );}

Для server context есть отдельный клиент:

import { createServerClient } from "@supabase/ssr";import { cookies } from "next/headers";export async functioncreateSupabaseServerClient() { const config = getSupabaseConfig(); if (!config) { return null; } const cookieStore = await cookies(); return createServerClient( config.url, config.publishableKey, { cookies: { getAll() { return cookieStore.getAll(); }, setAll(cookiesToSet) { try { cookiesToSet.forEach( ({ name, options, value }) => { cookieStore.set( name, value, options ); } ); } catch { // Context cannot write cookies. } }, }, } );}

Publishable key находится в клиентском bundle по определению. Ограничение доступа к пользовательским строкам поэтому нельзя строить вокруг идеи «спрячем ключ».
Для таблицы favorites эту задачу выполняет RLS. Service-role или другой привилегированный ключ в frontend не передаётся.

Таблица cloud favorites

В проекте строка избранного содержит пользователя, ID события и snapshot:

create table if not exists public.favorites ( user_id uuid not null references auth.users(id) on delete cascade, event_id text not null, event_snapshot jsonb not null, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), primary key (user_id, event_id));

Составной primary key решает ещё одну задачу, один пользователь не может создать две строки для одного event_id. При этом другой пользователь может сохранить то же событие.

user A + event 123 → alloweduser B + event 123 → alloweduser A + event 123 → duplicate

event_snapshot содержит внутреннюю модель карточки:

export type CloudFavoriteRow = { user_id: string; event_id: string; event_snapshot: EventCardData; created_at: string; updated_at: string;};

Это сознательно отличается от схемы, где в favorites хранится только внешний ID. Плюс snapshot в том, что страница избранного может рендерить сохранённые данные из своей таблицы. Ограничение тоже прямое, snapshot не обновляется сам, если исходное событие позже изменилось.
В текущей реализации нет отдельного UPDATE flow для таких snapshots.

RLS как граница доступа

После создания таблицы включается Row Level Security:

alter table public.favoritesenable row level security;

Browser-facing роль получает только нужные операции:

grant usage on schema publicto authenticated;grant select, insert, deleteon table public.favoritesto authenticated;

UPDATE здесь отсутствует.
Дальше добавляются три policy. Чтение своих строк:

create policy "favorites_select_own"on public.favoritesfor selectto authenticatedusing ( (select auth.uid()) = user_id);

Создание строки только для себя:

create policy "favorites_insert_own"on public.favoritesfor insertto authenticatedwith check ( (select auth.uid()) = user_id);

Удаление своих строк:

create policy "favorites_delete_own"on public.favoritesfor deleteto authenticatedusing ( (select auth.uid()) = user_id);

Frontend всё равно знает userId и может использовать его в запросе. Но JavaScript-фильтр не является контролем доступа. Например, cloud favorites загружаются так:

const { data, error } = await supabase .from("favorites") .select( "user_id,event_id,event_snapshot," + "created_at,updated_at" ) .eq("user_id", userId) .order("created_at", { ascending: false, });

.eq("user_id", userId) ограничивает запрос приложения. RLS независимо проверяет строки на стороне базы. Если изменить значение user_id в запросе из браузера, policy остаётся тем же.

Как hook выбирает guest или user mode

Общий cloud store содержит режим:

type CloudFavoriteMode = "guest" | "user";

При инициализации создаётся browser client и запрашивается пользователь:

const supabase = createSupabaseBrowserClient();if (!supabase) { // guest mode return;}const { data, error } = await supabase.auth.getUser();const userId = error ? null : data.user?.id ?? null;

Если пользователя нет, остаётся существующий localStorage flow.
Если userId есть, загружается public.favorites:

await loadCloudFavorites({ supabase, userId,});

На уровне hook выбор выглядит так:

const mode = snapshot.mode;const favoriteEvents = mode === "user" ? snapshot.events : localFavoriteEvents;const favoriteIds = mode === "user" ? snapshot.favoriteIds : localFavorites.favoriteIds;

Компонент страницы не делает отдельный branch для Supabase query. Он получает:

favoriteEventsfavoriteIdsisHydratedisLoadingmode

И может только показать подпись:

Browser favorites

или:

Account favorites

Миграция при первом входе

Здесь возникает основной переход между двумя режимами. Представим, что до регистрации пользователь сохранил три события:

localStorage:ABC

После входа в аккаунт в облаке уже может находиться:

Supabase:BD

Нельзя просто заменить один список другим. Тогда A и C исчезнут из интерфейса.
В проекте используется copy migration. Сначала читаются cloud rows, затем IDs превращаются в Set:

await migrateLocalFavoritesToCloud({ existingIds: new Set( rows.map((row) => row.event_id) ), supabase, userId,});

Из localStorage выбираются только отсутствующие события:

const localEvents = getFavoriteEvents().filter( (event) => !existingIds.has(event.id) );

Для них создаются новые rows:

const rows: CloudFavoriteInsert[] = localEvents.map((event) => ({ event_id: event.id, event_snapshot: event, user_id: userId, }));

Затем выполняется обычный insert:

const { error } = await supabase .from("favorites") .insert(rows);

При успешной записи локальные события добавляются в текущий cloud store.
Получается:

local:A B Ccloud before:B Dcloud after migration:A B C D

Почему миграция копирует, а не переносит

После успешного insert локальный storage не очищается. Это сохраняет гостевой контур браузера. Если пользователь позже выйдет из аккаунта, приложение снова перейдёт в guest mode и сможет использовать прежние browser favorites.
Важно понимать границу, текущая синхронизация односторонняя.

localStorage ↓ first sign inSupabase

Изменения cloud favorites обратно в localStorage не копируются. Поэтому после входа пользователь может добавить новые события только в account favorites, а после выхода гостевой список на этом браузере будет содержать старый локальный набор.
Это не двусторонняя offline-sync система.

Флаг миграции привязан к пользователю

Чтобы не копировать один и тот же local cache при каждой загрузке, используется отдельный key:

const MIGRATION_FLAG_PREFIX = "eventmap:favorites:migrated:";

Полный ключ содержит userId:

function getMigrationFlagKey( userId: string): string { return `${MIGRATION_FLAG_PREFIX}${userId}`;}

Например:

eventmap:favorites:migrated:

После успешной миграции:

window.localStorage.setItem( getMigrationFlagKey(userId), "1");

Если cloud insert вернул ошибку, flag не ставится. То есть неудачная попытка не отмечается как завершённая миграция. Флаг хранится в конкретном браузере. На другом устройстве действует свой localStorage и, соответственно, своя миграция локальных гостевых данных.

Почему здесь INSERT, а не upsert

В подобных схемах хочется заменить:

.insert(rows)

на:

.upsert(rows)

Но SQL policy проекта не содержит UPDATE. Есть только:

SELECTINSERTDELETE

upsert может перейти в update-сценарий при конфликте с существующей строкой. Тогда модель прав уже не совпадает с операциями приложения.
В проекте сначала загружаются существующие event_id, затем при миграции отбрасываются совпадения и выполняется insert. При обычном сохранении hook также сначала проверяет:

if ( cloudStore.favoriteIds.includes( event.id )) { return;}

Отдельный UPDATE для favorites сейчас не нужен.

Добавление с optimistic UI

Для вошедшего пользователя новое событие сначала добавляется в локальный cloud store:

const previousStore = cloudStore;const events = normaliseEvents([ event, ...cloudStore.events,]);patchCloudStore({ events, favoriteIds: getFavoriteIds(events),});

После этого выполняется insert:

const row: CloudFavoriteInsert = { event_id: event.id, event_snapshot: event, user_id: userId,};const { error } = await supabase .from("favorites") .insert(row);

Если база вернула ошибку:

if (error) { setCloudStore(previousStore);}

UI откатывается к предыдущему состоянию. Это не меняет роль RLS. Оптимистическое состояние существует только в интерфейсе, а запись всё равно проходит policy базы.

DELETE устроен симметрично

Сначала событие убирается из client store:

const previousStore = cloudStore;const events = cloudStore.events.filter( (event) => event.id !== id );patchCloudStore({ events, favoriteIds: getFavoriteIds(events),});

Потом выполняется запрос:

const { error } = await supabase .from("favorites") .delete() .eq("user_id", userId) .eq("event_id", id);

При ошибке возвращается previousStore. На уровне базы DELETE policy всё равно требует совпадения auth.uid() и user_id.

Auth нужен не только таблице

Для cloud mode сначала должна появиться Supabase session. В проекте login page поддерживает Magic Link и Google OAuth.
Magic Link:

await supabase.auth.signInWithOtp({ email, options: { emailRedirectTo: getRedirectTo(), },});

Google:

await supabase.auth.signInWithOAuth({ provider: "google", options: { redirectTo: getRedirectTo(), },});

Обе ветки возвращают пользователя на:

/auth/callback

Callback получает code:

const code = request.nextUrl.searchParams .get("code");

и меняет его на Supabase session:

const { error } = await supabase.auth .exchangeCodeForSession(code);

После этого пользователь возвращается на сохранённый внутренний route.

next нельзя использовать как произвольный redirect

Login может открываться с адресом:

/en/login?next=/en/favorites

После авторизации хочется вернуть пользователя туда, откуда начался flow. Но строку next нельзя без проверки передавать в redirect.
В проекте принимается только внутренний путь:

export function getSafeAuthNext( value: string | null, fallback: string): string { if (!value) { return fallback; } if (!value.startsWith("/")) { return fallback; } if (value.startsWith("//")) { return fallback; } try { const parsed = new URL( value, "https://eventmap.local" ); if ( parsed.origin !== "https://eventmap.local" ) { return fallback; } return ( `${parsed.pathname}` + `${parsed.search}` + `${parsed.hash}` ); } catch { return fallback; }}

Так callback не превращается в open redirect на переданный внешний домен.

Redirect URLs для localhost и production

Auth flow зависит не только от кода Next.js. Supabase должен разрешать адрес callback приложения. Для локальной разработки:

Site URL:http://localhost:3000Redirect URL:http://localhost:3000/auth/callback

Для production:

Site URL:https://Redirect URL:https:///auth/callback

У Google OAuth есть ещё один уровень. Google возвращает пользователя не напрямую в Next.js callback, а в Supabase Auth:

https://.supabase.co/auth/v1/callback

После обработки provider callback Supabase перенаправляет пользователя на разрешённый callback приложения. Схема получается такой:

Next.js ↓ signInWithOAuthGoogle ↓Supabase Auth callback ↓Next.js /auth/callback ↓ exchange codesafe next path

Эти URL лучше проверять отдельно для localhost и production. Ошибка в allow list может выглядеть как проблема React или callback route, хотя приложение до своего обработчика вообще не дошло.

Client island остаётся маленьким

Избранному нужен browser API и интерактивная кнопка, поэтому полностью server-only эта часть быть не может. Но из этого не следует перевод всей карточки или страницы в Client Component.
В проекте EventCard получает готовое EventCardData, а интерактивность находится в FavoriteButton.

Страница /favorites тоже остаётся отдельным route, а клиентская часть отвечает только за выбор источника, hydration и операции со списком. Граница получается такой:

Server-rendered application ↓EventCard ↓small FavoriteButton client island ↓useFavoritesCloud /  / localStorage Supabase

Что происходит без Supabase

Конфигурация может отсутствовать:

const supabase = createSupabaseBrowserClient();if (!supabase) { // guest}

То же происходит, если пользователь не авторизован. Поэтому добавление cloud favorites не делает guest mode зависимым от доступности Supabase для самого факта локального сохранения. С точки зрения UI это по-прежнему одна кнопка.

Текущие границы реализации

У схемы есть несколько конкретных ограничений. Cloud migration выполняется только из localStorage в Supabase. Обратной синхронизации нет. event_snapshot сохраняется в момент INSERT. UPDATE policy и механизм обновления snapshot в текущем варианте отсутствуют. Миграционный flag локален для браузера и пользователя.
Cloud favorites работают через browser client после hydration. Текущая схема не пытается превращать /favorites в серверно защищённую account page. Проверка .eq("user_id", userId) в frontend-запросе не заменяет RLS. Service-role key в browser flow не используется.
Эти границы важнее попытки сделать один hook ответственным сразу за полноценную offline-синхронизацию, account management и серверную авторизацию.

Итоговая схема

Основной поток укладывается в несколько состояний:

no session ↓localStorage favorites ↓user signs in ↓load cloud favorites ↓copy missing local snapshots ↓mark migration for this user/browser ↓use Supabase favoritesuser signs out ↓guest localStorage mode

Пользователь может сохранять события до регистрации. После входа существующие browser favorites копируются в account storage. RLS ограничивает строки текущим auth.uid(), а UI продолжает работать через один useFavoritesCloud.
Общий вариант с таблицей Supabase и базовыми RLS policies без слоя миграции описан в отдельной статье Как использовать Supabase вместо отдельного backend.

Источник: habr.com

0 0 голоса
Рейтинг новости
1
0
Подписаться
Уведомить о
0 комментариев