Generowanie treści z AI w CMS — automatyczne opisy produktów i meta tagi

Opublikowano
10 kwietnia 2026
Aktualizacja
26 czerwca 2026
Czas czytania
5 min czytania

Problem: tworzenie treści nie skaluje się

Nie, to nie jest tak, że AI pisze, a my bezkrytycznie publikujemy taką treść, tylko , a dopiero potem system je publikuje. Zbudujemy go na Next.js Server Actions, Vercel AI SDK i headless CMS-ie. Jeśli zależy Ci na generowaniu całych stron, a nie tylko pól treści, zerknij też do artykułu o programmatic SEO z Next.js i AI.

Automatyczne meta description i tytuły SEO

Sercem całego podejścia jest z Vercel AI SDK. Model zwraca obiekt zgodny ze schematem Zod, więc długość i struktura outputu są przewidywalne:

Code
// actions/ai-content.ts
'use server'
 
import { openai } from '@ai-sdk/openai'
import { generateObject, generateText } from 'ai'
import { z } from 'zod'
 
const metaSchema = z.object({
  metaTitle: z.string().max(60).describe('SEO title, max 60 znaków'),
  metaDescription: z
    .string()
    .max(160)
    .describe('Meta description, max 160 znaków, z call-to-action'),
  ogTitle: z.string().max(70).describe('Open Graph title'),
})
 
export async function generateMetaTags(content: string, pageType: string) {
  const { object } = await generateObject({
    model: openai('gpt-4o-mini'),
    schema: metaSchema,
    prompt: `Na podstawie poniższej treści wygeneruj meta tagi SEO po polsku.
Typ strony: ${pageType}
 
Treść:
${content.slice(0, 1500)}
 
Zasady:
- metaTitle: zawierać główne słowo kluczowe, max 60 znaków
- metaDescription: zachęcający, z call-to-action, max 160 znaków
- ogTitle: lekko inny niż metaTitle, bardziej angażujący, ale nadal zgodny z treścią`,
  })
 
  return object
}

Generowanie opisów produktów

Ten sam wzorzec przenosimy na opisy produktów (schemat Zod plus prompt z konkretnym kontekstem) i im bogatszy input (kategoria, specyfikacja, grupa docelowa), tym mniej generyczny, szablonowy i bardziej unikalny powstanie output:

Code
const productDescriptionSchema = z.object({
  shortDescription: z
    .string()
    .max(200)
    .describe('Krótki opis na karcie produktu'),
  fullDescription: z.string().max(1000).describe('Pełny opis z korzyściami'),
  bulletPoints: z
    .array(z.string())
    .max(5)
    .describe('Kluczowe cechy jako bullet points'),
  seoKeywords: z.array(z.string()).max(10).describe('Słowa kluczowe SEO'),
})
 
export async function generateProductDescription(product: {
  name: string
  category: string
  specs: string
  targetAudience?: string
}) {
  const { object } = await generateObject({
    model: openai('gpt-4o-mini'),
    schema: productDescriptionSchema,
    prompt: `Wygeneruj opis produktu po polsku:
Nazwa: ${product.name}
Kategoria: ${product.category}
Specyfikacja: ${product.specs}
${product.targetAudience ? `Grupa docelowa: ${product.targetAudience}` : ''}
 
Zasady:
- Używaj języka korzyści
- Używaj języka, którym mówi klient
- shortDescription: jedno zdanie, które ma zachęcić klienta
- fullDescription: 2-3 akapity, SEO-friendly
- bulletPoints: max 5, tylko konkretne korzyści`,
  })
 
  return object
}

Alt teksty do zdjęć produktów

Alt teksty to często pomijany element SEO obrazów, a przy setkach produktów pisanie ich ręcznie jest nierealne. Generujesz je analogicznie, pilnując limitu 125 znaków i słowa kluczowego. Jeśli chcesz dowiedziec się więcej o samej optymalizacji grafik znajdziesz w artykule o obrazach w Next.js:

Code
export async function generateAltTexts(
  images: { url: string; context: string }[],
) {
  const results = []
 
  for (const image of images) {
    const { text } = await generateText({
      model: openai('gpt-4o-mini'),
      prompt: `Wygeneruj alt text po polsku dla zdjęcia produktu.
Kontekst: ${image.context}
 
Zasady:
- Max 125 znaków
- Opisowy, nie "zdjęcie..."
- Zawierać słowo kluczowe produktu
- Naturalny język`,
    })
 
    results.push({ url: image.url, alt: text.trim() })
  }
 
  return results
}

Panel admina — pętla człowiek-w-środku

To najważniejszy element całego pipeline'u, ponieważ tutaj AI generuje propozycję, ale nic nie trafia do bazy, zanim człowiek tego nie zobaczy i nie zatwierdzi. Panel z podglądem i edycją zamienia masowe generowanie w kontrolowany proces:

Code
// app/admin/products/[id]/ai-panel.tsx
'use client'
 
import { useState } from 'react'
import {
  generateProductDescription,
  generateMetaTags,
} from '@/actions/ai-content'
 
export function AIContentPanel({ product }: { product: Product }) {
  const [generated, setGenerated] = useState<any>(null)
  const [isGenerating, setIsGenerating] = useState(false)
 
  async function handleGenerate() {
    setIsGenerating(true)
    const [description, meta] = await Promise.all([
      generateProductDescription({
        name: product.name,
        category: product.category,
        specs: product.specifications,
      }),
      generateMetaTags(product.name + ' ' + product.specifications, 'product'),
    ])
 
    setGenerated({ ...description, ...meta })
    setIsGenerating(false)
  }
 
  async function handleApply() {
    // Zapisz wygenerowane treści do CMS/bazy
    await updateProduct(product.id, generated)
  }
 
  return (
    <div className="space-y-4 rounded-xl border p-6">
      <div className="flex items-center justify-between">
        <h3 className="font-semibold">Asystent AI</h3>
        <button
          onClick={handleGenerate}
          disabled={isGenerating}
          className="rounded-lg bg-purple-600 px-4 py-2 text-sm text-white disabled:opacity-50"
        >
          {isGenerating ? 'Generuję...' : 'Wygeneruj treści'}
        </button>
      </div>
 
      {generated && (
        <div className="space-y-4">
          <div>
            <label className="text-sm font-medium text-gray-500">
              Krótki opis
            </label>
            <textarea
              defaultValue={generated.shortDescription}
              className="mt-1 w-full rounded-lg border p-3"
              rows={2}
            />
          </div>
 
          <div>
            <label className="text-sm font-medium text-gray-500">
              Pełny opis
            </label>
            <textarea
              defaultValue={generated.fullDescription}
              className="mt-1 w-full rounded-lg border p-3"
              rows={6}
            />
          </div>
 
          <div>
            <label className="text-sm font-medium text-gray-500">
              Meta title ({generated.metaTitle?.length}/60)
            </label>
            <input
              defaultValue={generated.metaTitle}
              className="mt-1 w-full rounded-lg border p-3"
            />
          </div>
 
          <div>
            <label className="text-sm font-medium text-gray-500">
              Meta description ({generated.metaDescription?.length}/160)
            </label>
            <textarea
              defaultValue={generated.metaDescription}
              className="mt-1 w-full rounded-lg border p-3"
              rows={2}
            />
          </div>
 
          <button
            onClick={handleApply}
            className="w-full rounded-lg bg-green-600 py-3 text-white"
          >
            Zatwierdź i zapisz
          </button>
        </div>
      )}
    </div>
  )
}

Zapis do headless CMS

W przykładach powyżej updateProduct i db.product są celowo abstrakcyjne, ponieważ w realnym projekcie po drugiej stronie stoi konkretny headless CMS. Wzorzec jest jednak zawsze ten sam. Bierzesz zwalidowany obiekt z generateObject i robisz z niego jeden zapis (patch/mutację) do rekordu. Dla Sanity wygląda to tak:

Code
// lib/cms.ts
import { createClient } from '@sanity/client'
 
const client = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset: process.env.SANITY_DATASET!,
  token: process.env.SANITY_WRITE_TOKEN!, // token z uprawnieniem do zapisu
  apiVersion: '2024-01-01',
  useCdn: false, // przy zapisie i świeżym odczycie wyłącz CDN
})
 
export async function saveGeneratedContent(
  productId: string,
  content: {
    shortDescription: string
    fullDescription: string
    metaTitle: string
    metaDescription: string
    seoKeywords: string[]
  },
) {
  return client
    .patch(productId)
    .set({
      ...content,
      aiGenerated: true, // ta sama para flag co w batchu
      needsReview: true,
    })
    .commit()
}

Token zapisu trzymaj wyłącznie po stronie serwera (Server Action albo skrypt batchowy). W innych CMS-ach zmienia się tylko API klienta. Contentful ma environment.getEntry().update(), Strapi i Payload wystawiają REST/GraphQL do PATCH-a rekordu. Logika pozostaje identyczna, czyli zapisujesz zwalidowany obiekt razem z flagami, które kolejkują treść do przeglądu.

Który model wybrać do generowania treści?

Przykłady używają gpt-4o-mini, ale Vercel AI SDK jest agnostyczny dostawcowo — zmiana sprowadza się do podmiany jednej linijki z modelem. Przy masowym generowaniu opisów liczą się dwie rzeczy: koszt za tysiące wywołań i jakość, która przejdzie Twoją redakcję. W praktyce dobrze sprawdza się układ dwóch modeli: szybki i tańszy do pierwszego draftu, mocniejszy do trudniejszych opisów albo finalnego szlifu.

Code
// Łatwa podmiana dostawcy — ten sam generateObject, inny model
import { anthropic } from '@ai-sdk/anthropic'
import { openai } from '@ai-sdk/openai'
 
const draftModel = openai('gpt-4o-mini') // szybki, tani draft
const polishModel = anthropic('claude-opus-4-8') // mocniejszy szlif

Wybór konkretnego dostawcy zależy od Twoich treści i budżetu, a różnice między rodzinami modeli rozkładam w porównaniu Claude, ChatGPT i Gemini dla developerów. Niezależnie od modelu zasada jest wspólna, że każdy output i tak trafia do przeglądu, zanim zostanie opublikowany.

Zanim odpalisz batch na całym katalogu, oszacuj kosztu i jego rząd wielkości. Jeden opis produktu to zwykle kilkaset tokenów wejścia (prompt plus dane produktu) i kilkaset tokenów wyjścia. Pomnóż to przez liczbę produktów i przez aktualną stawkę modelu do draftów za milion tokenów (osobno input, osobno output, ale zajrzyj do cennika dostawcy, bo zmienia się szybko). Dla katalogu rzędu setek produktów przy modelu klasy „mini" wychodzi kwota liczona w kilku złotych i jest to znikomy koszt wobec ilości pracy copywritera.

Tyle, że w przypadku dziesiątek tysięcy produktów albo mocniejszym modelu do każdego opisu, trzeba najpierw policzyć, a nie płakać przy fakturze.

Batch processing — generowanie masowe

Do batchów nie importuj Server Action bezpośrednio do skryptu Node. Lepiej w tym wypadku wynieść logikę generowania do zwykłej funkcji serwerowej w lib/, a Action potraktować jako cienki wrapper dla UI. Przy dużej liczbie wywołań część zapytań do modelu może się nie udać. To normalne — API potrafi zwrócić 429 przy limicie zapytań albo 5xx przy chwilowym problemie dostawcy. Dlatego nieudane próby ponawiamy, a przypadki, które nadal się nie powiodły, zapisujemy na osobnej liście.

Code
// scripts/generate-descriptions.ts
import { generateProductDescription } from '@/lib/ai-content'
import { db } from '@/lib/db'
 
async function withRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn()
    } catch (error) {
      if (attempt === maxRetries) throw error
      // Wykładniczy backoff: 1s, 2s, 4s — daje API czas na ochłonięcie
      const delay = 1000 * 2 ** attempt
      console.warn(`Próba ${attempt + 1} nieudana, ponawiam za ${delay}ms`)
      await new Promise((r) => setTimeout(r, delay))
    }
  }
  throw new Error('unreachable')
}
 
async function batchGenerate() {
  const products = await db.product.findMany({
    where: { description: null }, // Produkty bez opisu
  })
 
  console.log(`${products.length} produktów do wygenerowania`)
  const failed: string[] = []
 
  for (const product of products) {
    try {
      const description = await withRetry(() =>
        generateProductDescription({
          name: product.name,
          category: product.category,
          specs: product.specs || '',
        }),
      )
 
      await db.product.update({
        where: { id: product.id },
        data: {
          shortDescription: description.shortDescription,
          fullDescription: description.fullDescription,
          seoKeywords: description.seoKeywords,
          aiGenerated: true, // Oznacz jako wygenerowane przez AI
          needsReview: true, // Wymaga przeglądu człowieka
        },
      })
 
      console.log(`✓ ${product.name}`)
 
      // Rate limit — nie przekraczaj limitów API
      await new Promise((r) => setTimeout(r, 1000))
    } catch (error) {
      console.error(`✗ ${product.name}:`, error)
      failed.push(product.id) // do ponownego przejścia w kolejnym uruchomieniu
    }
  }
 
  if (failed.length > 0) {
    console.log(`Nie udało się: ${failed.length}. IDs: ${failed.join(', ')}`)
  }
}
 
batchGenerate()

Skrypt bierze tylko produkty bez opisu, więc ponowne uruchomienie dokończy te, które wcześniej padły, i nie ruszy już zapisanych. Jeśli chcesz regenerować istniejące opisy (np. po zmianie promptu), filtruj po fladze np. needsReview: true albo osobnym znaczniku wersji promptu (żeby nie nadpisać treści już zredagowanych ręcznie).

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

Często zadawane pytania

Czy Google karze za treści generowane przez AI?

Nie za sam fakt użycia AI. Google ocenia jakość i przydatność treści, a nie sposób jej powstania. Jasno komunikuje, że liczy się wartość dla użytkownika, więc Karane są treści niskiej jakości, spamowe albo publikowane masowo bez realnej wartości oraz bez redakcji. Dlatego każdy taki opis powinien być redagowany/zatwierdzany przez człowieka. Innymi słowy, AI tworzy draft, człowiek nadaje mu jakość i unikalność.

Zacznij od szybkiego i tańszego modelu do pierwszego draft, ponieważ przy masowym generowaniu opisów koszt i czas mają realne znaczenie. Droższy, mocniejszy model zostaw dla trudniejszych opisów albo finalnego szlifu tam, gdzie jakość jest krytyczna.

Po pierwsze, instruuj model wprost w promptcie: „unikaj szablonowych zwrotów, każdy opis ma mieć własny ton i perspektywę". Po drugie, podawaj zróżnicowany kontekst dla każdego produktu, bo to on najmocniej wpływa na unikalność (chodzi o grupę docelową, sezon, zastosowanie). Im bogatszy i bardziej konkretny input, tym mniej szablonowy output. Generyczny prompt na kiepskich danych zawsze da generyczne, podobne do siebie, niskiej jakości teksty.

Bo Server Action to funkcja przeznaczona do wywołania z interfejsu w kontekście żądania, a nie do uruchamiania w skrypcie Node poza tym kontekstem. Czystszym wzorcem jest wyniesienie samej logiki generowania do zwykłej funkcji serwerowej w lib/, z której korzysta zarówno skrypt batchowy, jak i Server Action (jako cienki wrapper dla UI). Dzięki temu masz jedną implementację, którą wywołujesz z dwóch miejsc, bez naginania Server Action do roli, do której nie został stworzony.

Tak, bo redakcja gotowego draftu jest wielokrotnie szybsza niż pisanie od zera. Człowiek nie zaczyna z pustą stroną, tylko poprawia i zatwierdza propozycję, która ma już właściwą strukturę, długość meta description i bullet pointy wymuszone schematem Zod. Dla sklepu z setkami produktów oznacza to różnicę między tygodniami pisania a godzinami przeglądu. Sam koszt API przy modelach do draftów jest niski względem czasu pracy, który oszczędzasz, a kontrola jakości zostaje po stronie człowieka.

Żeby zachować kontrolę nad pipeline'em jakości. Flaga aiGenerated pozwala później odróżnić treści napisane przez człowieka od wygenerowanych. Jest to przydatne przy audytach i ewentualnym przeglądzie. needsReview kolejkuje wygenerowane opisy do akceptacji, więc nic nie trafia na produkcję bez sprawdzenia. W ten sposób mamy kontrolowany proces: AI produkuje propozycje, a system pilnuje, że każda przechodzi przez ludzką redakcję, zanim stanie się widoczna dla klientów.

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
Programmatic SEO z Next.js i AI — jak generować tysiące zoptymalizowanych stron

Klasyczne SEO skaluje się jednym sposobem: więcej ludzi piszących więcej artykułów. W 2026 roku, dzięki Next.js z ISR / SSG i modelom AI do treści, ta reguła się zmienia. Teraz nawet mała firma ma szansę pokryć rynek long-tail , na który nigdy nie opłacałoby się pisać ręcznie. Kluczem jest wiedzieć, jak to zrobić, ponieważ nie każda serwowana Google treść jest indeksowana...

Maciej Sala

Maciej Sala

Founder StriveLab

Astro i Headless CMS: Integracja Sanity, Storyblok, Strapi

Markdown w plikach projektu działa sprawnie, dopóki treść piszą osoby, które swobodnie pracują w Git. W sytuacji, kiedy klient chce sam zmienić cennik, a redaktor poprawia nagłówek w piątek po południu, repozytorium przestaje być wygodnym CMS-em. Wtedy najlepszy będzie headless CMS, czyli panel dla redakcji i API dla Astro.

Maciej Sala

Maciej Sala

Founder StriveLab

Vercel AI SDK — streaming chatbot w Next.js w 30 minut

Vercel AI SDK zdejmuje z Twoich barków większość żmudnej roboty przy dodawaniu AI do aplikacji w Next.js. Zapomnij o ręcznym czytaniu i zarządzaniu strumieniem bajtów z API, parsowaniu chunków czy doklejaniu poprzednich wypowiedzi do historii rozmowy. W tym tutorialu zbudujesz działającego, streamującego chatbota w mniej więcej 30 minut — dwa pliki i kilka linii konfiguracji.

Maciej Sala

Maciej Sala

Founder StriveLab