# ZapiszPrzepis: zapisuje przepis, nie link

Aplikacja PWA, która z udostępnionego posta, rolki albo bloga robi trwałą, uporządkowaną kopię przepisu po polsku, a z kopii publiczną bazę przepisów. Next.js, Supabase, Inngest, Cloudflare. Przetestowana na telefonach z Androidem.

**Canonical:** https://spoko.space/pl/zapiszprzepis/  
**Language:** pl  
**Published:** 2026-10-24  
**Updated:** 2026-10-27  
**Tags:** nextjs, typescript, supabase, pwa, openai, cloudflare  
**Category:** Portfolio  
**GitHub:** https://github.com/spokospace/zapiszprzepis

---
<div class="text-center flex gap-4 justify-center flex-wrap">

</div>

## Problem: zapisany link to nie przepis {#problem-zapisany-link-to-nie-przepis}

Przepisy krążą jako posty, rolki i filmy. Odruch to zakładka albo przycisk „Zapisz" na platformie, ale oba przechowują **wskaźnik, nie treść**. Po kilku miesiącach część zapisanych linków przestaje działać: autor kasuje rolkę, grupa znika, blog przestaje istnieć, post staje się prywatny. Kolekcja zamienia się w listę błędów 404, a człowiek dowiaduje się o tym wtedy, gdy chce z niej skorzystać.

ZapiszPrzepis działa odwrotnie, w modelu *archive-first*. Link zamienia się w trwałą kopię przepisu w bazie, a adres źródła zostaje obok, żeby oryginał był o jedno kliknięcie dalej, dopóki istnieje.

Projektowałem to dla osoby nietechnicznej, która nie wpisuje składników ręcznie, nie wypełnia formularzy i nie układa folderów ani tagów. Ten warunek wyciął większość „oczywistych" funkcji i okazał się najlepszym filtrem w całym projekcie.

To ciąg dalszy [wpisu o tym, jak pracuję z AI na prawdziwym projekcie](https://spoko.space/pl/blog/zapiszprzepis-aplikacja-ai-first/). Tamten tekst opisuje metodę: specyfikacje przed kodem, bramki, testy. Ten opisuje produkt: co zbudowałem, jak działa i co zmieniło się od sierpnia, gdy zaczęto z niego naprawdę korzystać.

## Z prywatnej kolekcji do publicznej bazy {#z-prywatnej-kolekcji-do-publicznej-bazy}

Pierwsza wersja była prywatną kolekcją jednego użytkownika. Największa zmiana od sierpnia to przebudowa na **publiczną bazę przepisów**.

- **Jeden link, jedna strona.** Każdy przepis ma publiczny adres `/przepis/{slug}`. Dodanie linku, który już jest w bazie, nie tworzy duplikatu, tylko dodaje istniejący przepis do ulubionych. Scalone duplikaty przekierowują (301) na przepis, który został.
- **Goście czytają bez konta.** Strona główna, przepisy, kategorie i wyszukiwarka są otwarte. Dostęp do danych dla gości idzie przez widoki, które domyślnie niczego nie ujawniają, więc pomyłka w uprawnieniach kończy się brakiem danych, a nie wyciekiem.
- **Konto daje własną kolekcję.** Ulubione, lista „Moje przepisy" i publiczny profil pod `/u/{nazwa}`. Dodawanie przepisów z linków jest za kodem dostępu.
- **Wiele wersji tego samego dania.** Przepisy na to samo danie są grupowane („Mamy jeszcze 2 inne przepisy na krokiety"), a różnice w składnikach oznaczone kolorem. Podobne przepisy nie podsuwają dań mięsnych osobie, która ogląda bezmięsne.

## Jak to działa {#jak-to-działa}

Wejście to jeden gest. Aplikacja rejestruje się jako **Web Share Target**, więc pojawia się w systemowym menu „Udostępnij" obok Messengera i WhatsAppa. Użytkownik wysyła link i na tym kończy.

**Co dzieje się po udostępnieniu linku**

1. **Rozpoznanie źródła** — Post na Facebooku, rolka, film z YouTube i blog to cztery różne przypadki, każdy z własnym sposobem pobrania treści.
2. **Sprawdzenie duplikatu** — Ten sam przepis opublikowany dwa razy nie trafia do bazy drugi raz, nawet pod innym adresem.
3. **Pobranie treści** — Wiele dróg i zapasowe plany, gdy pierwsza zawiedzie.
4. **Redakcja** — Model wyciąga przepis i porządkuje go według stałych reguł.
5. **Kontrola** — Bez składników albo bez kroków nic nie trafia do bazy, a w powiadomieniach pojawia się informacja, że na stronie nie ma przepisu.
6. **Etykiety** — Drugi, mocniejszy przebieg nadaje tytułowi właściwą formę, dodaje tagi i opis.
7. **Zapis** — Przepis trafia do bazy, a zdjęcie do własnego magazynu.

Wyciąganie przepisu trwa kilkanaście sekund i bywa kapryśne, więc nie dzieje się w trakcie żądania. Aplikacja wysyła zdarzenie do **Inngest**, który ustawia zadanie w kolejce i ponawia je przy błędzie. Użytkownik dostaje odpowiedź od razu, a przepis pojawia się chwilę później. Jeśli import się nie uda, powód trafia do powiadomień i można go ponowić stamtąd. Import zawieszony w stanie „oczekuje" jest traktowany jako nieudany, żeby nic nie wisiało w nieskończoność.

## Redakcja: z posta do przepisu {#redakcja-z-posta-do-przepisu}

Samo zapisanie treści nie wystarcza. Post z Facebooka to zwykle narracja („pokroiłam, zeszkliłam, dodałam"), pełna wtrętów i zachęt. Przepis w aplikacji ma wyglądać tak samo niezależnie od źródła, więc reguły redakcji są częścią produktu.

- **Kroki to krótkie polecenia w bezokoliczniku:** „Pokroić mięso w kostkę.", „Zeszklić cebulę.". Narracja i tryb rozkazujący są przepisywane na tę formę, znikają wtręty typu „u mnie" i słowa „następnie", „potem", „na koniec", bo kroki są numerowane. Słowa mówiące o czasie („gdy woda się zagotuje") zostają. Przepisy zapisane wcześniej poprawia osobny skrypt (w wydaniu 0.2.0 objął 609 z nich), a nowe dostają tę formę od razu.
- **Kolejność poprawiana tylko tam, gdzie trzeba.** Składnik, który źródło przygotowuje po kroku, który go używa (lukier, kruszonka, sos), przesuwam przed ten krok. Końcowe „Smacznego!" wypada, a porady o przechowywaniu i wariantach trafiają do jednego kroku „Opcjonalnie:".
- **Nic nie jest dopisywane.** Gdy post podaje składniki, a metodę zostawia filmowi, kroki zostają puste. Czasu, którego źródło nie podaje, model może się domyślić, ale wtedy jest on oznaczony jako szacunkowy. Liczby porcji nie szacuję wcale.
- **Źródła obcojęzyczne.** Przepis z angielskiej strony dostaje polskie nazwy produktów (makarony jako „literki", „kółka", „małe rurki") i polskie sformułowania bez kalk, a amerykańskie oznaczenia w rodzaju „94% chude" są upraszczane do „chude". Miary amerykańskie są przeliczane na metryczne.
- **Miary domowe.** Gdy źródło podaje obok wagi miarę z kuchni („250 g mąki – ok. 1,5 szklanki"), pokazuję ją jako drugą, jaśniejszą linię pod ilością. Model przepisuje ją dosłownie ze źródła i nigdy jej nie szacuje. Dla podstawowych produktów sypkich miarę domową wylicza osobno sama aplikacja, z tabeli gęstości, tylko przy wyświetlaniu.
- **Pozostałe porządki:** tytuł jak w menu (bez „Jak zrobić…", bez „najlepszy", bez pierwszej osoby), sekcje składników (Ciasto, Krem, Masa), jednostki amerykańskie przeliczone na metryczne, kategoria według reguł (ciasteczka, ciasta, pieczywo, syropy i kolejne).

Dwa przebiegi to świadomy podział kosztów. Tańszy model wyciąga treść, a mocniejszy, który dostaje jeden gotowy przepis, nadaje etykiety i tagi. Z tańszym tagi wychodziły źle: oznaczał bułki i pizzę jako „kruche". Model do wyciągania treści wybieram na własnych testach, nie z rankingu: po teście na przepisach przeszedłem na gpt-5.4-mini, a szerszy test 21 modeli na polskich i angielskich przepisach potwierdził ten wybór (czołówka to statystyczny remis).

**Jak wybrałem model:** [test 21 modeli AI na 50 polskich i angielskich przepisach](https://spoko.space/pl/blog/wybor-modelu-ai-zapiszprzepis/), z wzorcem sprawdzonym ręcznie, metodą i wynikami.

## Źródła i zapasowe plany {#źródła-i-zapasowe-plany}

Pierwsza wersja czytała blogi i YouTube. Od tamtej pory największa część pracy poszła w to, żeby link „po prostu działał", bo w praktyce źródła zawodzą na różne sposoby.

| Źródło | Jak odczytuję przepis |
| --- | --- |
| Facebook: rolki i filmy | Opis z wbudowanego odtwarzacza, bez sesji Facebooka. Gdy post odsyła do bloga, idę za linkiem. Gdy Facebook odmawia, aplikacja mówi, co naprawdę zobaczyła, zamiast zgadywać. |
| Facebook: posty i grupy | Osobne ścieżki dla stron i grup, bo ich posty udostępniają się inaczej. Czytam też pierwszy komentarz autora, a zdjęcia z albumów i wielu zdjęć w poście grupy trafiają do galerii. Link udostępniony z aplikacji i ten sam wklejony z przeglądarki mają ten sam klucz. |
| YouTube | Opis filmu, a gdy nie ma w nim przepisu, transkrypcja. Film jest osadzany tylko wtedy, gdy da się go osadzić. |
| Blogi | Treść główna strony, a dla Bloggera (także na własnych domenach) kanał JSON. Przepis rozbity na kilka stron WordPressa jest składany w całość, a galerie Wix czytane w pełnym rozmiarze. |
| Gdy coś zawodzi | Zapasowe czytanie strony przez Jina Reader (z odzyskiwaniem okładki i oczyszczaniem HTML), a powód porażki zapisuje się przy imporcie. |

Do tego dochodzi **deduplikacja w kilku warstwach**. Ten sam link znany pod kilkoma adresami (udostępnienie z aplikacji Facebooka, stały link do posta, nazwa grupy w innej formie) dostaje jeden klucz. Powtórka tego samego autora jest wyłapywana po odcisku jego tekstu, jeszcze zanim zostanie wywołany model, a ta, która różni się tekstem, po prawie identycznym zestawie składników (także wtedy, gdy dwa zapisy trafiają niemal jednocześnie).

## Zdjęcia i źródła na kartach {#zdjęcia-i-źródła-na-kartach}

Zdjęcie przepisu to osobny problem. Pierwszy obrazek na stronie bywa logo, reklamą albo miniaturą innego przepisu.

- **Okładka i galeria wybierane z treści.** Odrzucam zdjęcia z sekcji reklam, powiązanych wpisów i komentarzy, przyciski, logotypy sklepów, miniatury filmów i obrazki, które są linkiem do innej strony. Dla blogów ze złym `og:image` wybieram właściwe zdjęcie sam.
- **Kopia zdjęć u siebie.** Okładka i galeria są archiwizowane w magazynie aplikacji, bo adresy z Facebooka wygasają w ciągu dni. Okładki z hostów z błędem SSL albo odrzucających ruch z Cloudflare są odzyskiwane osobną drogą.
- **Zdjęcia kroków.** Gdy źródło ma zdjęcie przy konkretnym kroku, jest ono pokazywane przy tym kroku (dla zalogowanych).
- **Skąd jest przepis.** Plakietka źródła pokazuje logo Facebooka i autora albo domenę bloga. Osoba prywatna występuje tylko jako imię i inicjał nazwiska, bo pełnego nazwiska nigdy nie zapisuję.

## Wygoda na co dzień {#wygoda-na-co-dzień}

<div class="not-prose my-8 grid grid-cols-2 md:grid-cols-4 gap-4">
  <figure class="m-0">
    
    <figcaption class="mt-2 text-sm text-center">Strona główna z wyszukiwaniem głosem</figcaption>
  </figure>
  <figure class="m-0">
    
    <figcaption class="mt-2 text-sm text-center">Przepis i lista składników z polami wyboru</figcaption>
  </figure>
  <figure class="m-0">
    
    <figcaption class="mt-2 text-sm text-center">Tryb gotowania: jeden krok na ekranie</figcaption>
  </figure>
  <figure class="m-0">
    
    <figcaption class="mt-2 text-sm text-center">Kategoria z filtrem po tagach</figcaption>
  </figure>
</div>

- **Tryb gotowania.** Pokazuje jeden krok na pełnym ekranie, dużą czcionką, z paskiem postępu i przyciskami „Wstecz" i „Dalej", a ekran telefonu nie gaśnie w trakcie (Wake Lock), bo przy mące na rękach nikt nie chce go odblokowywać. Na samej stronie przepisu składniki mają pola wyboru: zaznaczony składnik się przekreśla, a „Wyczyść zaznaczenia" zeruje listę.
- **Wyszukiwanie.** Jedna wyszukiwarka z zakresem „wszystkie / moje / ulubione", podpowiedziami podczas pisania, ostatnimi wyszukiwaniami i **głosem** (przycisk „Powiedz" i ekran „Słucham…"). Osobno wyszukiwarka przepisów w internecie, która korzysta tylko z zaufanych stron i ma limit zapytań na minutę.
- **Kategorie i tagi.** Kategorie z licznikami, a w kategorii wybór kilku tagów naraz, które zawężają listę (przepis musi mieć wszystkie wybrane).
- **Ulubione.** Serce z podpisem na kartach i na zdjęciu, osobna lista. Kliknięcie przez osobę niezalogowaną zapamiętuje zamiar i dokańcza go po zalogowaniu.
- **Drukowanie.** Przycisk „Drukuj" i osobny arkusz stylów do druku: składniki i kroki w dwóch kolumnach, małe zdjęcie przy nagłówku, bez linii źródła i bez zdjęć kroków.
- **Przepis dnia** na stronie głównej, który zmienia się o polskiej północy, a nie o północy UTC.
- **Ustawienia wyglądu.** Wielkość tekstu skaluje całą aplikację, razem z opisem przepisu. Ta strona jest dostępna także bez logowania, bo osoba, której drobny druk przeszkadza, powinna to zmienić, zanim się zaloguje. Do tego wybór koloru akcentu.

## Dostępność i prawdziwy telefon {#dostępność-i-prawdziwy-telefon}

Aplikacja ma być czytelna dla osoby, która nie lubi małego druku, więc przebudowa publiczna szła pod **WCAG 2.2 AA**: duże cele dotykowe (48 px), brak przewijania w poziomie przy 320 px szerokości i przy powiększonej systemowej czcionce, przycisk „Wróć" zamiast gubienia się w nawigacji. Nazwy kategorii nie łamią się w środku słowa nawet przy dużej czcionce, a spójniki jednoliterowe („i", „w", „z") nie zostają na końcu wiersza.

Testuję na dwóch telefonach z Androidem, **Pixel 11 Pro** i **Pixel 9**, jako zainstalowaną PWA, a nie tylko w oknie przeglądarki. To znalazło błędy, których emulator nie pokazuje: kolor paska systemowego i dolnej nawigacji na Androidzie, przez który przyciski mogły zniknąć na białym tle. Naprawiłem to ciemnym tłem w manifeście i dynamicznym `theme-color`.

## Widoczność w wyszukiwarkach i dla modeli AI {#widoczność-w-wyszukiwarkach-i-dla-modeli-ai}

- **Dane strukturalne.** Strony przepisów mają schema.org/Recipe w JSON-LD, dzięki czemu Google może pokazać przepis jako rozszerzony wynik.
- **Opisy.** Opis do wyników wyszukiwania powstaje przy zapisie przepisu, a kategorie mają własne tytuły, opisy i karty do udostępniania.
- **Mapa strony i robots.txt.** Search Console odczytał mapę dwa dni po wysłaniu (status Success) i znalazł w niej 824 adresy. Strony szybko pojawiły się w indeksie Google.
- **Dla modeli językowych.** Każdy przepis i każda kategoria ma wersję w Markdownie, a w katalogu głównym leży `llms.txt` z listą stron, więc asystenci AI mogą cytować przepis z jego źródła, a nie ze zgadywania.

## Logowanie i dostęp {#logowanie-i-dostęp}

Podstawą jest logowanie linkiem w mailu, bez hasła, który działa na dowolnym urządzeniu: link otwarty w innej przeglądarce niż ta, w której zaczęło się logowanie, nie kończy się już błędem. Doszło logowanie przez **Google** i przez **Facebooka**. Przycisk Facebooka czekał, aż aplikacja Meta przejdzie na tryb Live, i od 24 października jest włączony. Po pierwszym logowaniu aplikacja pyta raz o formę zwrotu, żeby powitać po polsku w wołaczu i napisać „Dodała" albo „Dodał" w publicznych tekstach, a nie „Witaj, Użytkowniku".

Wylogowanie z jednego urządzenia nie wylogowuje już ze wszystkich, a chwilowy błąd sprawdzenia sesji nie wyrzuca użytkownika z aplikacji.

## Bezpieczeństwo i niezawodność {#bezpieczeństwo-i-niezawodność}

- **Odczyty gości fail-closed.** Publiczne strony czytają dane przez widoki z listą dozwolonych pól, a granica tej listy ma własne testy.
- **Zdjęcia przez listę dozwolonych.** Optymalizator obrazów przepuszcza tylko zarchiwizowane zdjęcia przepisów, a adresy z sieci prywatnych są odrzucane przy pobieraniu.
- **Zapisy tylko przez serwer.** Zapis do tabeli przepisów i importów jest odebrany sesji użytkownika i zostaje przy roli serwisowej, więc zablokowanie dotyczy bazy, a nie tylko kodu aplikacji (po audycie kodu). Przekierowanie po logowaniu nie pozwala już wyskoczyć poza serwis.
- **Nagłówki bezpieczeństwa** ustawiane w warstwie pośredniej na każdej odpowiedzi.
- **Service worker bez stron z konta.** Tryb offline pokazuje zapisane publiczne strony albo komunikat „Offline", ale strony zalogowanego użytkownika nie trafiają do pamięci podręcznej, a po wylogowaniu pamięć jest czyszczona. Wcześniej zapasowa ścieżka nie działała (zawsze brała pierwszą gałąź), więc to też była realna poprawka.
- **Kopia zapasowa i czuwanie.** Tabele z przepisami są zrzucane do R2 codziennie, a projekt w Supabase dostaje codzienne zapytanie, żeby nie zasypiał.
- **Sprawdzenie produkcji.** Po wdrożeniu skrypt sprawdza, czy pliki statyczne naprawdę się ładują, i zgłasza sporadyczne błędy stron.

## Technologie {#technologie}

| Warstwa | Wybór i powód |
| --- | --- |
| Framework | **Next.js 16** (App Router): Server Actions, formularze bez osobnego API |
| Baza i logowanie | **Supabase**: Postgres z RLS, link w mailu, Google i Facebook |
| Zadania w tle | **Inngest**: kolejka i ponawianie bez własnej infrastruktury |
| Pobieranie i redakcja | **Firecrawl, Jina Reader, OpenAI** (dwa przebiegi: wyciąganie i etykiety) |
| Hosting | **Cloudflare Workers** (OpenNext), kopia zapasowa do R2 |
| Klient | **PWA + Web Share Target**: jedyny sposób na jednoruchowe wejście |

Hosting na Cloudflare Workers oznacza brak API Node.js w czasie działania, więc każda integracja musi działać na zwykłym `fetch`. To wyeliminowało kilka bibliotek jeszcze przed pierwszą linijką kodu.

## Wnioski {#wnioski}

- **Archiwum kopii wygrywa z zakładką**, bo przepis nie zależy od życia źródła.
- **Reguły redakcji są produktem**, nie dodatkiem. To one sprawiają, że przepisy z różnych źródeł czyta się tak samo.
- **Źródła zawodzą po cichu**, więc pomagają zapasowe plany, uczciwe komunikaty i powiadomienia z możliwością ponowienia.
- **Jeden link, jedna strona** pozwoliła zamienić prywatną listę w bazę, którą da się czytać, linkować i znaleźć w Google.
- **Prawdziwy telefon znajduje to, czego nie widać na komputerze.**

Jak do tego doszedłem, czyli specyfikacje przed kodem, potok agentów, audyt kodu i testy, opisuję we [wpisie o pracy z AI](https://spoko.space/pl/blog/zapiszprzepis-aplikacja-ai-first/).

## Najczęściej zadawane pytania {#faq}

### Czym ZapiszPrzepis różni się od zakładki w przeglądarce? {#faq-czym-zapiszprzepis-różni-się-od-zakładki-w-przeglądarce}

Zakładka przechowuje <strong>wskaźnik</strong>, który działa tylko dopóki istnieje źródło. ZapiszPrzepis zapisuje <strong>kopię treści</strong>: tytuł, składniki, kroki i zdjęcie trafiają do własnej bazy. Adres źródła zostaje obok, ale przepis przetrwa, gdy autor skasuje post.

### Czy trzeba mieć konto, żeby czytać przepisy? {#faq-czy-trzeba-mieć-konto-żeby-czytać-przepisy}

Nie. Strony przepisów, kategorie, wyszukiwarka i ustawienia wyglądu są dostępne dla gości. Konto (link w mailu, Google albo Facebook) jest potrzebne do ulubionych i własnej kolekcji, a dodawanie przepisów z linków chroni kod dostępu.

### Czy model AI zmyśla składniki albo kroki? {#faq-czy-model-ai-zmyśla-składniki-albo-kroki}

Prompt każe zapisać tylko to, co podaje źródło. Gdy post ma składniki, a metodę zostawia filmowi, lista kroków zostaje pusta. Czas przygotowania, którego źródło nie podaje, jest oznaczony jako szacunkowy, a liczby porcji nie szacuję w ogóle. Strona, która przepisu nie zawiera (zapowiedź kursu, wpis z samym linkiem do artykułu gdzie indziej), kończy się komunikatem, a nie zmyślonym przepisem.

### Co się dzieje, gdy ten sam przepis zapisze dwoje ludzi? {#faq-co-się-dzieje-gdy-ten-sam-przepis-zapisze-dwoje-ludzi}

Jeden link ma jedną stronę. Dodanie linku, który już jest w bazie, nie tworzy kopii, tylko dodaje ten przepis do ulubionych, a scalone duplikaty przekierowują na przepis, który został.
