Programmatic SEO bez długu technicznego w Astro, PostgreSQL i n8n

Opublikowano
4 czerwca 2026
Aktualizacja
26 czerwca 2026
Czas czytania
9 min czytania

Kiedy strona zasługuje na własny URL — kwalifikacja przed programmatic SEO

Programmatic SEO często psuje się już na starcie, ponieważ ktoś widzi listę miast, usług albo produktów i myśli: „zrobimy stronę dla każdej kombinacji". Potem powstaje 1200 podstron, które różnią się nazwą miasta i jednym akapitem. W taki sposób, zamiast projektu, powstaje generator problemów.

Zanim zbudujesz cokolwiek, musisz sprawdzić trzy rzeczy:

  1. Czy każdy URL odpowiada na realną intencję?
  2. Czy masz dane unikalne dla tej strony?
  3. Czy strona pomaga użytkownikowi podjąć decyzję?

Dobrym przykładem, może być „koszt montażu pompy ciepła w Krakowie" z lokalnymi widełkami cen, czasem realizacji, możliwością dodania opinii przez klientów, warunkami dotacji i przykładami budynków.

Architektura programmatic SEO: Astro, PostgreSQL i n8n

W tym wariancie stack wygląda tak:

  • PostgreSQL trzyma rekordy, statusy, dane źródłowe i wyniki walidacji.
  • n8n pobiera dane z API, arkuszy albo formularzy, wzbogaca je i uruchamia workflow review.
  • Astro podczas builda pobiera tylko rekordy gotowe do publikacji i generuje statyczne strony.
  • CI/CD sprawdza metadane, sitemapę, duplikaty i błędy szablonu.
  • CDN serwuje gotowy HTML.

Nie potrzebujesz aplikacji renderującej każdą stronę na żądanie, jeśli dane zmieniają się raz dziennie albo raz w tygodniu. Statyczny build jest prostszy, tańszy i mniej podatny na awarie.

Dług w programmatic SEO bierze się z pewnego braku symetrii. Opublikowanie nowej strony to jedno kliknięcie, a wycofanie słabej z indeksu Google to dni roboty. W rezultacie, bałagan rośnie sam.

Model danych w PostgreSQL: statusy publikacji i kontrola jakości

Zacznij od tabeli, która nie udaje CMS-a, ale trzyma wszystko, co decyduje o publikacji.

Code
CREATE TYPE landing_status AS ENUM (
  'draft',
  'ready',
  'published',
  'rejected',
  'archived'
);
 
CREATE TABLE programmatic_landings (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  slug TEXT NOT NULL UNIQUE,
  intent TEXT NOT NULL,
  segment TEXT NOT NULL,
  locale TEXT NOT NULL DEFAULT 'pl',
  title TEXT NOT NULL,
  meta_description TEXT NOT NULL,
  canonical_url TEXT,
  h1 TEXT NOT NULL,
  intro TEXT NOT NULL,
  facts JSONB NOT NULL DEFAULT '{}'::jsonb,
  qa_notes JSONB NOT NULL DEFAULT '{}'::jsonb,
  quality_score INTEGER NOT NULL DEFAULT 0,
  source_hash TEXT,
  indexable BOOLEAN NOT NULL DEFAULT false,
  reviewed_by TEXT,
  reviewed_at TIMESTAMPTZ,
  redirect_to TEXT,
  status landing_status NOT NULL DEFAULT 'draft',
  published_at TIMESTAMPTZ,
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Najważniejsze pole to status. Bez niego pipeline szybko zamieni się w tunel do publikacji wszystkiego, co da się wygenerować. Drugie pole, którego nie pomijaj, to indexable. Strona może być gotowa technicznie, ale nadal nie powinna trafić do indeksu, jeśli brakuje review, canonicala, danych unikalnych albo sensownego miejsca w architekturze linkowania.

source_hash rozwiązuje nudny, ale kosztowny problem idempotencji. Jeśli n8n pobiera ten sam rekord z arkusza albo API po raz trzeci, hash pozwala rozpoznać, że dane się nie zmieniły. Nie generujesz wtedy nowego eventu, nie nadpisujesz ręcznej korekty i nie uruchamiasz review bez powodu.

Dodaj też prostą tabelę zdarzeń:

Code
CREATE TABLE landing_events (
  id BIGSERIAL PRIMARY KEY,
  landing_id UUID NOT NULL REFERENCES programmatic_landings(id),
  event_type TEXT NOT NULL,
  payload JSONB NOT NULL DEFAULT '{}'::jsonb,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Dzięki temu widzisz historię: import, wzbogacenie danych, odrzucenie, poprawkę, publikację, archiwizację. Przy setkach stron to ratuje rozmowy z zespołem.

Workflow w n8n: import, wzbogacanie danych i review przed publikacją

n8n świetnie pasuje do pracy między narzędziami. Może pobrać dane z arkusza, odpalić zapytanie w PostgreSQL, wezwać API, wysłać Slacka i zapisać wynik. Nie powinien jednak samodzielnie wrzucać każdej strony do produkcji.

Rozsądny workflow:

  1. trigger z harmonogramu albo formularza,
  2. pobranie nowych rekordów,
  3. normalizacja slugów i pól,
  4. wzbogacenie danych z API lub hurtowni,
  5. walidacja jakości,
  6. porównanie source_hash z ostatnią wersją rekordu,
  7. zapis do PostgreSQL jako draft albo ready,
  8. powiadomienie osoby odpowiedzialnej za review.

Przykład walidacji w n8n może być prosty:

Code
const item = $json
const sourceHash = $crypto
  .createHash('sha256')
  .update(JSON.stringify(item.source))
  .digest('hex')
 
const errors = []
 
if (!item.slug || !/^[a-z0-9-]+$/.test(item.slug)) {
  errors.push('invalid_slug')
}
 
if ((item.meta_description || '').length < 90) {
  errors.push('short_meta_description')
}
 
if (!item.facts?.price_range && !item.facts?.local_data) {
  errors.push('missing_unique_data')
}
 
const qualityScore = [
  item.slug,
  item.title,
  item.meta_description,
  item.facts?.price_range || item.facts?.local_data,
  item.intent,
].filter(Boolean).length
 
return {
  ...item,
  source_hash: sourceHash,
  qa_notes: {
    errors,
    passed: errors.length === 0,
  },
  quality_score: qualityScore,
  indexable: false,
  status: errors.length === 0 && qualityScore >= 5 ? 'ready' : 'draft',
}

To nie jest redakcja. To bramka. Strona bez danych unikalnych nie przechodzi dalej.

Idempotencja w n8n jest równie ważna jak walidacja. Workflow uruchamiany cyklicznie powinien umieć powiedzieć: „ten rekord już znam i nic się nie zmieniło". Wtedy pomijasz aktualizację albo dopisujesz tylko zdarzenie skipped_unchanged. Bez tego automatyzacja zaczyna produkować szum: Slack dostaje powiadomienia bez powodu, reviewer widzi te same strony, a historia rekordu traci wartość.

Astro: generowanie statycznych stron tylko z opublikowanych rekordów

Astro dobrze gra z takim modelem, bo dynamiczne trasy możesz wygenerować podczas builda. W getStaticPaths() pobierasz tylko rekordy ze statusem published.

Code
---
// src/pages/[...slug].astro
import { db } from '../lib/db'
import Layout from '../layouts/Layout.astro'
import ProgrammaticLanding from '../components/ProgrammaticLanding.astro'
 
export async function getStaticPaths() {
  const landings = await db.query.programmaticLandings.findMany({
    where: (landing, { and, eq }) =>
      and(eq(landing.status, 'published'), eq(landing.indexable, true)),
  })
 
  return landings.map((landing) => ({
    params: { slug: landing.slug.split('/') },
    props: { landing },
  }))
}
 
const { landing } = Astro.props
---
 
<Layout
  title={landing.title}
  description={landing.meta_description}
  canonical={landing.canonical_url ?? `/${landing.slug}/`}
>
  <ProgrammaticLanding landing={landing} />
</Layout>

Ten fragment ma jedną zaletę: publikacja jest decyzją w danych. Jeśli rekord ma draft, Astro go nie wygeneruje. Nie pojawi się w HTML-u, sitemapie ani linkowaniu wewnętrznym.

Przy trasie src/pages/[...slug].astro pamiętaj, że Astro oczekuje tablicy segmentów dla rest parameter. Jeśli w bazie trzymasz uslugi/pompy-ciepla-krakow, do params.slug przekaż landing.slug.split('/'). To drobiazg, ale przy programmatic SEO takie drobiazgi potrafią wygenerować setki błędnych URL-i.

Szablon strony bez thin contentu: stała struktura, unikalne dane

Dobry szablon nie powinien udawać ręcznie pisanego artykułu. Powinien jasno pokazać dane, które użytkownik chce porównać.

Przykładowe sekcje dla lokalnej usługi:

  • cena lub widełki,
  • dostępność,
  • wymagania,
  • porównanie wariantów,
  • lokalne ograniczenia,
  • pytania i odpowiedzi,
  • następny krok.

W Astro komponent może wymuszać brak publikacji, jeśli brakuje danych.

Code
---
const { landing } = Astro.props
const facts = landing.facts
 
if (!facts.price_range && !facts.local_data) {
  throw new Error(`Landing ${landing.slug} has no unique facts`)
}
---
 
<article>
  <h1>{landing.h1}</h1>
  <p>{landing.intro}</p>
 
  {facts.price_range && (
    <section>
      <h2>Ceny</h2>
      <p>{facts.price_range}</p>
    </section>
  )}
 
  {facts.local_data && (
    <section>
      <h2>Dane lokalne</h2>
      <p>{facts.local_data}</p>
    </section>
  )}
</article>

Build ma prawo się wywalić. Lepiej zatrzymać wdrożenie niż wypuścić 300 pustych stron.

Sitemap i canonical w programmatic SEO bez duplikatów

Sitemapę generuj z tego samego źródła co strony, przykładowo jeśli getStaticPaths() bierze tylko published, sitemap też musi brać tylko published. W innym wypadku, Google dostanie adresy, których strona nie pokazuje użytkownikom albo które nie powinny zostać indeksowane.

W Astro możesz zrobić to prostym endpointem XML:

Code
// src/pages/sitemap-programmatic.xml.ts
import { db } from '../lib/db'
 
const SITE_URL = 'https://example.com'
 
export async function GET() {
  const landings = await db.query.programmaticLandings.findMany({
    where: (landing, { and, eq }) =>
      and(eq(landing.status, 'published'), eq(landing.indexable, true)),
    columns: {
      slug: true,
      updatedAt: true,
      canonicalUrl: true,
    },
  })
 
  const urls = landings
    .map((landing) => {
      const loc = landing.canonicalUrl ?? `${SITE_URL}/${landing.slug}/`
      return `<url><loc>${loc}</loc><lastmod>${landing.updatedAt.toISOString()}</lastmod></url>`
    })
    .join('')
 
  return new Response(
    `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`,
    {
      headers: { 'Content-Type': 'application/xml' },
    },
  )
}

Canonical ustawiaj na finalny URL bez parametrów. Przy programmatic SEO unikaj wariantów typu:

Code
/uslugi/pompy-ciepla-krakow/?source=import-17

Finalny adres powinien wyglądać tak:

Code
/uslugi/pompy-ciepla-krakow/

Parametry zostaw analityce. Indeks ma dostać czysty adres.

Noindex, redirect 301 i archiwizacja jako jeden spójny system

Status rekordu powinien mówić, co ma zobaczyć użytkownik oraz co ma zobaczyć robot i warstwy nie mogą się rozjechać.

StatusStrona HTMLSitemapRobots metaRedirect
draftnie generujniebrak, bo brak stronynie
readynie generujniebrak, bo brak stronynie
published + indexable=truegenerujtakindex, follownie
published + indexable=falsegeneruj tylko jeśli ma sens dla użytkownikanienoindex, followopcjonalnie
archivednie generuj starej stronyniebraktak, jeśli istnieje odpowiednik
rejectednie generujniebraknie

Najlepszym modelem jest, by publicznie indeksowalne były tylko rekordy published z indexable=true. Wszystko inne albo nie powinno istnieć w buildzie, albo ma wyraźny powód, żeby pozostać dostępne bez indeksacji. Dzięki temu sitemap, linkowanie wewnętrzne i HTML jest ze sobą spójne.

Redirect trzymaj jako dane, nie jako losowy wpis dopisany ręcznie po wdrożeniu:

Code
SELECT slug, redirect_to
FROM programmatic_landings
WHERE status = 'archived'
  AND redirect_to IS NOT NULL;

Na tej podstawie możesz wygenerować reguły dla hostingu albo plik konfiguracyjny deploya. Gdy ktoś zapyta, dlaczego stary URL przekierowuje akurat tam, odpowiedź jest w bazie i historii zdarzeń, nie w pamięci osoby, która robiła porządki trzy miesiące temu.

QA przed publikacją, czyli bramki jakości blokujące treść o niskiej jakości

Musimy zdecydować się na jakieś minimum jakościowe i będzie to:

  • unikalny slug,
  • unikalny title w obrębie segmentu,
  • description nie jest puste,
  • strona ma unikatowe dane,
  • canonical wskazuje finalny URL,
  • strona ma status published,
  • indexable jest ustawione świadomie,
  • sitemap zawiera tylko strony published,
  • HTML nie zawiera przypadkowego noindex,
  • tekst nie jest niemal identyczny z innymi stronami w segmencie.

Duplikaty title znajdziesz w SQL:

Code
SELECT title, COUNT(*) AS count
FROM programmatic_landings
WHERE status IN ('ready', 'published')
GROUP BY title
HAVING COUNT(*) > 1;

Strony bez danych:

Code
SELECT slug
FROM programmatic_landings
WHERE status IN ('ready', 'published')
  AND NOT (facts ? 'price_range' OR facts ? 'local_data');

Rekordy opublikowane bez decyzji indeksacyjnej:

Code
SELECT slug, status, indexable, quality_score
FROM programmatic_landings
WHERE status = 'published'
  AND indexable = false
  AND redirect_to IS NULL;

To są proste zapytania i właśnie dlatego powinny działać przy każdym deployu.

Generowanie treści AI w programmatic SEO — gdzie pomaga, a gdzie szkodzi

AI może pomóc, ale nie może być usprawiedliwieniem dla pustej strony.

Zastosowanie, którego trzeba się trzymać:

  • streszczenie danych w krótkim intro,
  • normalizacja tonu,
  • generowanie FAQ na podstawie realnych danych,
  • wykrywanie braków w rekordzie,
  • tworzenie wariantów title do review.

Nieprawidłowe zastosowanie, które zaszkodzi projektowi:

  • generowanie przykładowo 1000 tekstów bez danych i ich automatyczna publikacja bez review,
  • udawanie lokalnej wiedzy, której nie posiadasz,
  • zalewanie sitemapy stronami, których nikt nie powinien znaleźć.

Upraszczajac, jeśli AI pisze tekst, baza danych nadal musi dostarczać autentyczne i sprawdzone informacje.

Kiedy archiwizować strony w programmatic SEO

Publikacja to czas zweryfikować sens istnienia strony. Strona bez wejść przez 6–12 miesięcy nie zawsze oznacza, że jest problemem, ale jeśli jest niskiej jakości, nikt do niej nie linkuje, nie zbiera ruchu i nie ma biznesowego powodu, żeby istniała, usuń ją albo scal z mocniejszą.

W PostgreSQL dodaj status archived, a nie kasuj rekordu od razu. Dzięki temu masz historię i możesz utrzymać redirect.

Code
UPDATE programmatic_landings
SET status = 'archived',
    updated_at = now()
WHERE slug = 'uslugi/stara-kombinacja/'
RETURNING id, slug;

Potem dodaj redirect do najbliższego sensownego odpowiednika. Nie przekierowuj wszystkiego na stronę główną tylko dlatego, że jest wygodnie.

Monitoring w Google Search Console po publikacji

Z doświadczenia wiem, że deploy to mocna weryfikacja Programmatic SEO. Przy dużych wzorcach URL Google Search Console jest systemem alarmowym, bo pokazuje problemy, których nie zobaczysz w lokalnym buildzie.

Monitoruj szczególnie:

  • Crawled, currently not indexed, czyli Google widzi stronę, ale nie uznaje jej za wartą indeksacji.

  • Duplicate without user-selected canonical, czyli warianty URL albo zbyt podobne strony konkurują ze sobą.

  • Alternate page with proper canonical tag: zwykle OK, ale przy programmatic SEO sprawdź, czy canonical nie wskazuje przypadkiem na stronę zbiorczą.

  • Soft 404: strona istnieje technicznie, ale wygląda jak pusta albo nie przedstawia wartości.

  • Discovered, currently not indexed, oznacza zbyt dużo URL-i i zbyt słaby sygnał jakości albo linkowania.

  • Zero impressions po 6-12 miesiącach to dobry kandydat do przeglądu, przebudowy, wzbogacenia treści, a w ostateczności do usunięcia.

Minimalny rytm utrzymania to miesięczny raport per segment: ile stron opublikowano, ile zaindeksowano, ile ma impresje, ile zebrało kliknięcia, ile wpadło w soft 404 lub duplicate canonical. Bez tego programmatic SEO zamienia się w jednorazową akcję publikacyjną, a dług techniczny narasta cicho w indeksie.

Do bazy możesz dopisywać snapshoty z GSC:

Code
CREATE TABLE landing_search_metrics (
  landing_id UUID NOT NULL REFERENCES programmatic_landings(id),
  date DATE NOT NULL,
  impressions INTEGER NOT NULL DEFAULT 0,
  clicks INTEGER NOT NULL DEFAULT 0,
  indexed BOOLEAN,
  coverage_state TEXT,
  PRIMARY KEY (landing_id, date)
);

To nie musi być idealna hurtownia danych. Wystarczy, że po kwartale wiesz, które segmenty działają, które są martwe i które trzeba przebudować, zanim zaczną obciążać crawl budget.

Audyt techniczny i optymalizacja pod kątem SEO i GEO.
Audyt techniczny SEO

Często zadawane pytania

Czym ten układ różni się od programmatic SEO z Next.js i AI?

Next.js ma sens, gdy strony często zmieniają się po stronie serwera albo potrzebujesz dynamicznych funkcji aplikacji. Astro lepiej pasuje do statycznych katalogów landing page, gdzie liczy się prosty HTML, niski koszt hostingu i szybkie buildy kontrolowane danymi.

Nie polecam takiego trybu. n8n powinien zbierać dane, wzbogacać rekordy, uruchamiać walidacje i tworzyć wersje robocze. Publikacja powinna przejść przez review albo przynajmniej przez twarde bramki jakości.

Każda strona musi mieć własne dane, własną odpowiedź na intencję i powód istnienia. Jeśli różni się tylko nazwą miasta albo produktu, a reszta jest tym samym tekstem, to nie jest skalowanie. To śmietnik.

Nie, jeśli baza jest źródłem danych do buildu, a nie runtime dependency każdej wizyty. Astro może pobrać rekordy podczas builda i wygenerować statyczne HTML-e. Użytkownik dostaje szybki plik z CDN.

Gdy nie masz unikalnych danych, jasnych segmentów intencji albo procesu kontroli jakości. Sam szablon i lista miast nie wystarczą. Lepiej opublikować 40 dobrych stron niż 4 tysiące prawie identycznych.

Nie. Sitemap powinna korzystać z tego samego filtra co getStaticPaths(), czyli z rekordów naprawdę opublikowanych i indeksowalnych. Status ready oznacza, że strona przeszła automatyczną walidację i czeka na review albo publikację. Dopóki nie ma statusu published, finalnego canonicala i decyzji o indeksacji, nie powinna pojawić się w sitemapie ani w linkowaniu wewnętrznym.

O autorze

Maciej Sala

Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwija własne projekty side-projecty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy

Czytaj dalej

Zobacz więcej wpisów