Vercel AI SDK i budowa strumieniującego chatbota w Next.js
Strumieniujący chatbot z Vercel AI SDK w aplikacji Next.js. Zobacz jak przejść od zera do działającego interfejsu z OpenAI lub Claude bez zbędnego kodu boilerplate.
Maciej Sala
Founder StriveLab
7 min czytaniaOpublikowano 10 kwietnia 2026 (Aktualizacja 24 lipca 2026)
Czym jest Vercel AI SDK w Next.js?
to biblioteka TypeScript, która
zdejmuje z dewelopera większość żmudnej roboty przy dodawaniu AI do aplikacji w
Next.js: ręczne zarządzanie strumieniem bajtów, parsowanie chunków, doklejanie
historii rozmowy do kolejnych zapytań.
Pakiet dostarcza dwie główne warstwy narzędzi. Po stronie klienta są hooki
useChat i useCompletion, które obsługują stan oraz aktualizacje
. Po stronie serwera znajdują się streamText i
generateText, które komunikują się z modelem i zwracają wynik w
ustandaryzowanym formacie. Adaptery pozwalają zachować podobną architekturę przy
OpenAI, Anthropic, Google czy Mistralu. Nie oznacza to jednak identycznego
wsparcia wszystkich możliwości przez każdy model.
W czacie opartym na AI SDK 7 interfejs operuje na UIMessage[]. Ten format
zawiera identyfikatory i tablicę parts, dzięki czemu może reprezentować tekst,
pliki, dane oraz wywołania narzędzi. Model otrzymuje prostszy format
ModelMessage[], dlatego Route Handler używa asynchronicznego
convertToModelMessages().
Instalacja Vercel AI SDK w projekcie Next.js
Instalujesz paczkę rdzeniową, integrację React i adapter wybranego dostawcy.
Warto przypiąć główne wersje w package.json, ponieważ kolejne wydania SDK mogą
zmieniać API:
AI SDK 7 wymaga Node.js 22 lub nowszego i nie obsługuje CommonJS require().
Do nowego projektu produkcyjnego warto wybrać aktualną wersję LTS Node.js i
sprawdzić tę samą wersję w środowisku lokalnym, CI oraz na hostingu.
Adaptery domyślnie odczytują klucze z serwerowych zmiennych środowiskowych. Dla
OpenAI utwórz lokalny plik .env.local:
Code
OPENAI_API_KEY=sk-proj-...
Nie dodawaj prefiksu NEXT_PUBLIC_, ponieważ udostępniłby sekret kodowi
klienckiemu. Plik .env.local nie powinien trafić do repozytorium. Dla
Anthropic odpowiednią zmienną jest ANTHROPIC_API_KEY.
Streaming chatbot w Next.js w dwóch plikach
Route Handler jako backend chatbota AI
Code
// app/api/chat/route.tsimport { openai } from '@ai-sdk/openai'import { convertToModelMessages, createUIMessageStreamResponse, safeValidateUIMessages, streamText, toUIMessageStream,} from 'ai'export const maxDuration = 30export async function POST(request: Request) { const body: unknown = await request.json().catch(() => null) if ( typeof body !== 'object' || body === null || !('messages' in body) || !Array.isArray(body.messages) || JSON.stringify(body).length > 100_000 ) { return Response.json( { error: 'Nieprawidłowe dane wejściowe' }, { status: 400 }, ) } const validation = await safeValidateUIMessages({ messages: body.messages, }) if ( !validation.success || validation.data.length > 50 || validation.data.some((message) => message.role === 'system') ) { return Response.json( { error: 'Nieprawidłowa historia rozmowy' }, { status: 400 }, ) } const messages = validation.data const inputCharacters = messages.reduce( (total, message) => total + message.parts.reduce( (sum, part) => sum + (part.type === 'text' ? part.text.length : 0), 0, ), 0, ) if (inputCharacters > 30_000) { return Response.json( { error: 'Historia rozmowy jest zbyt długa' }, { status: 413 }, ) } const result = streamText({ model: openai('gpt-5-mini'), instructions: 'Jesteś pomocnym asystentem. Odpowiadaj konkretnie i wyłącznie po polsku.', messages: await convertToModelMessages(messages), maxOutputTokens: 800, abortSignal: request.signal, timeout: { totalMs: 25_000, chunkMs: 10_000 }, }) const uiStream = toUIMessageStream({ stream: result.stream, sendReasoning: false, onError(error) { console.error('Generowanie odpowiedzi nie powiodło się', { errorName: error instanceof Error ? error.name : 'UnknownError', }) return 'Nie udało się wygenerować odpowiedzi.' }, }) return createUIMessageStreamResponse({ stream: uiStream })}
safeValidateUIMessages() sprawdza strukturę wiadomości, ale limit biznesowy
nadal należy do aplikacji. W przykładzie ograniczamy rozmiar body, liczbę
wiadomości, łączną długość tekstu, długość odpowiedzi i czas oczekiwania na
model. Odrzucamy też wiadomości z rolą system, ponieważ instrukcje mogą
pochodzić wyłącznie z zaufanego kodu serwerowego.
request.signal pozwala przerwać wywołanie po rozłączeniu klienta lub użyciu
przycisku Stop. maxDuration ustawia limit funkcji na hostingu, natomiast
timeout ogranicza samą operację generowania. Ustawienie
sendReasoning: false zapobiega przesyłaniu części reasoning do przeglądarki,
jeśli wybrany model je zwraca.
To cały rdzeń prototypu. SDK opiera interfejs na wiadomościach z tablicą
parts, a backend przekształca wynik przez toUIMessageStream() i zwraca
Response z createUIMessageStreamResponse(). Komponent obsługuje też błąd,
ponowienie odpowiedzi i anulowanie generowania. Odpowiedzi serwera pozostają
ogólne, aby nie ujawniać użytkownikowi komunikatów dostawcy ani szczegółów
infrastruktury.
Jak działa streaming odpowiedzi w Vercel AI SDK?
streamText() nie czeka na kompletny wynik. Zwraca strumień zdarzeń, a
toUIMessageStream() przekształca zdarzenia modelu na protokół UI Message
Stream. createUIMessageStreamResponse() koduje go w formacie SSE. Po stronie
klienta useChat odbiera fragmenty tekstu i aktualizuje tablicę wiadomości.
Fragment nie musi odpowiadać jednemu tokenowi modelu, dlatego określenie „token
po tokenie” jest uproszczeniem.
Streaming poprawia czas do pierwszego widocznego fragmentu odpowiedzi, ale nie
przyspiesza samego modelu. Po drodze proxy i middleware nie mogą buforować całej
odpowiedzi. Jeśli lokalnie widzisz tekst na bieżąco, a po wdrożeniu dopiero na
końcu, sprawdź kompresję i buforowanie na warstwie pośredniej.
Zmiana dostawcy modelu AI jedną linią kodu
Code
// app/api/chat/route.tsimport { anthropic } from '@ai-sdk/anthropic'import { convertToModelMessages, createUIMessageStreamResponse, streamText, toUIMessageStream, type UIMessage,} from 'ai'export async function POST(req: Request) { const { messages }: { messages: UIMessage[] } = await req.json() const result = streamText({ model: anthropic('claude-sonnet-4-6'), instructions: 'Jesteś inżynierem Next.js ze znajomością SEO. Odpowiadaj po polsku.', messages: await convertToModelMessages(messages), }) const uiStream = toUIMessageStream({ stream: result.stream, sendReasoning: false, }) return createUIMessageStreamResponse({ stream: uiStream })}
W podstawowym czacie reszta kodu pozostaje bez zmian. Przy zmianie modelu
sprawdź jednak obsługę obrazów, narzędzi, structured output, limit kontekstu i
opcje reasoning. Wspólny interfejs SDK nie wyrównuje różnic między API
dostawców.
System prompt dla chatbota w Next.js
Prompt systemowy opisuje rolę modelu, styl odpowiedzi i zakres tematyczny. Dla
asystenta biznesowego może wyglądać tak:
Code
const systemPrompt = `Jesteś asystentem StriveLab.Zasady odpowiedzi:- odpowiadaj wyłącznie po polsku;- pisz konkretnie i bez zbędnych wstępów;- odpowiadaj tylko na pytania o inżynierię webową i tworzenie oprogramowania;- gdy użytkownik pyta o wycenę, skieruj go do formularza kontaktowego.Fakty, z których możesz korzystać:- StriveLab specjalizuje się w Next.js, technicznym SEO i stronach konwersyjnych;- biuro znajduje się w Krakowie;- adres kontaktowy to kontakt@strivelab.pl.Jeśli nie znasz odpowiedzi, powiedz o tym wprost. Nie wymyślaj faktów.`const result = streamText({ model: openai('gpt-5-mini'), instructions: systemPrompt, messages: await convertToModelMessages(messages),})
System prompt wpływa na zachowanie modelu, ale nie stanowi mechanizmu
autoryzacji. Użytkownik może próbować go obejść przez prompt injection. Dostęp
do danych i możliwość wykonania operacji trzeba kontrolować w kodzie narzędzia
oraz na podstawie sesji użytkownika.
useCompletion do generowania tekstu bez historii rozmowy
useCompletion przydaje się tam, gdzie nie potrzebujesz kontekstu rozmowy.
Przykładami są jednorazowe tłumaczenie, opis produktu lub szybka sugestia. Hook
zarządza jednym promptem i jednym wynikiem:
// app/api/generate/route.tsimport { openai } from '@ai-sdk/openai'import { createUIMessageStreamResponse, streamText, toUIMessageStream,} from 'ai'export async function POST(request: Request) { const body: unknown = await request.json().catch(() => null) if ( typeof body !== 'object' || body === null || !('prompt' in body) || typeof body.prompt !== 'string' || body.prompt.length > 4000 || JSON.stringify(body).length > 10_000 ) { return Response.json({ error: 'Nieprawidłowy prompt' }, { status: 400 }) } const result = streamText({ model: openai('gpt-5-mini'), instructions: 'Tworzysz rzeczowe opisy produktów po polsku. Nie dodawaj niepotwierdzonych cech.', prompt: `Napisz opis produktu w 2 lub 3 zdaniach na podstawie tych danych:\n\n${body.prompt}`, maxOutputTokens: 250, abortSignal: request.signal, }) const uiStream = toUIMessageStream({ stream: result.stream, sendReasoning: false, }) return createUIMessageStreamResponse({ stream: uiStream })}
generateText w Server Actions bez streamingu
Gdy potrzebujesz gotowego wyniku do dalszego przetworzenia, generateText
zwraca kompletny tekst po zakończeniu wywołania. Dotyczy to na przykład
generowania metadanych, tłumaczenia wsadowego i podsumowania:
Code
// actions/ai-actions.ts'use server'import { openai } from '@ai-sdk/openai'import { generateText } from 'ai'export async function generateMetaDescription(content: string) { const { text } = await generateText({ model: openai('gpt-5-mini'), instructions: 'Tworzysz precyzyjne opisy stron. Zwracasz wyłącznie gotowy tekst.', prompt: `Napisz meta description o długości do 160 znaków na podstawie tego wstępu:\n\n${content.slice(0, 2000)}`, maxOutputTokens: 100, }) return text}export async function translateText(text: string, targetLang: string) { const { text: translated } = await generateText({ model: openai('gpt-5-mini'), instructions: 'Tłumacz wiernie. Zwracaj wyłącznie tłumaczenie bez komentarza.', prompt: `Język docelowy: ${targetLang}\n\nTekst:\n${text}`, maxOutputTokens: 2000, }) return translated}
Server Action nadal jest publicznym punktem wejścia wywoływanym przez klienta.
Przed wysłaniem treści do modelu sprawdź sesję, długość danych i dozwolone
wartości, na przykład kod języka z zamkniętej listy. Sam typ TypeScript nie
waliduje danych w runtime.
Structured output z walidacją Zod
Funkcje generateObject() i streamObject() są przestarzałe.
Aktualny interfejs korzysta z generateText() lub streamText() oraz
Output.object(). Schemat Zod opisuje oczekiwany kształt i waliduje wynik:
Code
import { openai } from '@ai-sdk/openai'import { generateText, NoObjectGeneratedError, Output } from 'ai'import { z } from 'zod'const productSchema = z.object({ title: z.string().min(3).max(120), description: z .string() .min(20) .max(500) .describe('Opis oparty wyłącznie na danych wejściowych'), tags: z.array(z.string().min(2).max(40)).min(1).max(5), category: z.enum(['elektronika', 'dom', 'sport', 'inne']),})export async function generateProductData(rawDescription: string) { try { const { output } = await generateText({ model: openai('gpt-5-mini'), output: Output.object({ schema: productSchema }), prompt: `Przygotuj dane produktu na podstawie tego opisu:\n\n${rawDescription.slice(0, 4000)}`, }) return output } catch (error) { if (NoObjectGeneratedError.isInstance(error)) { throw new Error('Model nie zwrócił poprawnych danych produktu') } throw error }}
Schemat zmniejsza ryzyko błędnego formatu, ale nie potwierdza prawdziwości
danych. Wygenerowaną kategorię, cenę czy parametry produktu nadal trzeba
porównać z regułami domenowymi przed zapisem do bazy.
Bezpieczeństwo i kontrola kosztów chatbota AI
Walidacja z pierwszego przykładu nie zastępuje autoryzacji i limitów użycia. W
wersji produkcyjnej Route Handler powinien co najmniej identyfikować
użytkownika, ograniczać częstotliwość żądań i rejestrować faktyczne użycie
modelu:
W publicznym czacie logowanie nie zawsze jest wymagane, ale limit nadal musi być
powiązany z wiarygodnym identyfikatorem, na przykład sesją lub odpowiednio
przetworzonym adresem IP. Sam adres IP ma ograniczenia związane z NAT, sieciami
mobilnymi i prywatnością. Limit liczby żądań warto uzupełnić dziennym budżetem
tokenów na użytkownika lub organizację.
Nie wysyłaj do logów treści promptów ani pełnych odpowiedzi bez świadomej
decyzji dotyczącej danych osobowych i retencji. Rejestrowanie liczby tokenów,
czasu odpowiedzi, modelu, kodu zakończenia i identyfikatora żądania zwykle
wystarcza do kontroli kosztów oraz diagnostyki.
Pamięć rozmowy i zapis wiadomości
useChat zarządza stanem bieżącego komponentu, lecz nie tworzy automatycznie
trwałej pamięci. W prostym przykładzie przeglądarka przesyła całą aktualną
tablicę wiadomości przy kolejnym żądaniu, a odświeżenie strony usuwa historię z
interfejsu.
Jeśli rozmowa ma przetrwać odświeżenie, nadaj jej chatId, zapisz
UIMessage[] w bazie i sprawdzaj, czy zalogowany użytkownik jest właścicielem
rozmowy. Przy zapisie kompletnej odpowiedzi przydaje się onEnd:
W produkcji bezpieczniej jest przyjąć od klienta identyfikator rozmowy i nową
wiadomość, a wcześniejszą historię odczytać z bazy po stronie serwera. Klient
może zmienić przesyłaną tablicę, podszyć się pod wiadomość asystenta albo
usunąć wcześniejsze instrukcje. Dane z przeglądarki nigdy nie powinny być
autorytatywną historią dla operacji mających skutki biznesowe.
Ograniczanie kontekstu bez utraty sensu rozmowy
messages.slice(-20) jest prostym zabezpieczeniem demonstracyjnym, ale nie
kontroluje rzeczywistego rozmiaru kontekstu. Dwadzieścia krótkich pytań i
dwadzieścia wiadomości z dużymi załącznikami mają zupełnie inny koszt.
W aplikacji produkcyjnej ustal budżet tokenów dla wejścia, zawsze zachowaj
niezbędne instrukcje systemowe i najnowszą część rozmowy, a starsze fragmenty
podsumowuj lub pobieraj selektywnie. Limity dostawcy są granicą techniczną.
Własny, niższy budżet kontekstu jest granicą kosztową i jakościową.
Czego system prompt nie zabezpiecza
System prompt może ograniczyć styl i zakres odpowiedzi, lecz model nadal jest
podatny na prompt injection oraz błędne wnioski. Jeśli dodasz narzędzia do
wysyłania wiadomości, odczytu CRM albo wykonywania płatności, ich funkcje
execute muszą niezależnie sprawdzać sesję, uprawnienia, dane wejściowe i
zakres operacji. Dla działań nieodwracalnych dodaj potwierdzenie użytkownika.
Bezpieczne automatyzacje procesów i agenci AI w n8n, Make i Claude.
Vercel AI SDK to otwartoźródłowy zestaw bibliotek TypeScript do budowania funkcji opartych na modelach AI. AI SDK Core ujednolica komunikację z dostawcami modeli, AI SDK UI dostarcza hooki takie jak useChat i useCompletion, a osobne adaptery integrują OpenAI, Anthropic, Google i innych dostawców.
Z jakimi modelami dogaduje się Vercel AI SDK?
SDK ma adaptery między innymi dla OpenAI, Anthropic, Google, Mistral, Cohere, xAI, Groq i Amazon Bedrock. Dostępne są też integracje społecznościowe z modelami lokalnymi. Prosty czat tekstowy zwykle wymaga tylko zmiany adaptera i identyfikatora modelu, ale obsługa narzędzi, reasoning, obrazów oraz structured output różni się między dostawcami i wymaga sprawdzenia macierzy funkcji.
Jak to wygląda kosztowo w utrzymaniu?
Sam SDK jest otwartoźródłowy. Płacisz za użycie modelu oraz infrastrukturę, na której działa Route Handler. Koszt zależy od liczby tokenów wejściowych i wyjściowych, długości przesyłanej historii, użycia reasoning oraz wywołań narzędzi. Nie warto podawać jednej ceny rozmowy, ponieważ cenniki i zachowanie modeli się zmieniają. Mierz rzeczywiste użycie w onEnd, ustaw alerty budżetowe i limity wyjścia.
Kiedy łapać za useChat, a kiedy za useCompletion?
Jeśli Twój cel to klasyczny, konwersacyjny chatbot z pamięcią poprzednich wiadomości, wybierz useChat. Jeżeli potrzebujesz prostej, jednorazowej strzały bez kontekstu (np. klikasz guzik i generuje się opis przedmiotu do sklepu albo robisz szybkie tłumaczenie na żywo), useCompletion będzie prostszy. Do danych strukturalnych w AI SDK 7 użyj generateText z Output.object() i schematem Zod. Hook useObject pozostaje przydatny, gdy obiekt ma być przesyłany strumieniowo do interfejsu.
Czy streaming danych od AI będzie śmigał z Edge Runtime?
SDK może działać w środowiskach Node.js, Edge i Cloudflare Workers, ale adapter dostawcy oraz użyte zależności muszą wspierać wybrany runtime. W Next.js nie trzeba wybierać Edge tylko po to, aby strumieniować odpowiedź. Na Vercelu Edge musi zacząć wysyłać odpowiedź w określonym czasie, a maksymalny czas funkcji Node.js zależy od planu i konfiguracji Fluid Compute. maxDuration jest limitem hostingu, nie timeoutem zapytania do modelu.
Jak ochronić takiego chatbota przed trollami i zjadaczami budżetu?
Wymagaj autoryzacji tam, gdzie czat nie jest publiczny, stosuj rate limiting, ogranicz rozmiar body i liczbę wiadomości, ustaw maxOutputTokens oraz timeout. Historię ograniczaj według budżetu tokenów, a nie wyłącznie liczby wiadomości. Klucze API przechowuj tylko po stronie serwera. Jeśli model może uruchamiać narzędzia, każdą operację autoryzuj niezależnie, ponieważ system prompt nie jest granicą bezpieczeństwa.
O autorze
Maciej Sala
Maciej Sala — konsultant technologiczny produktów cyfrowych i web 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.
Model Context Protocol standaryzuje sposób, w jaki aplikacja AI odkrywa narzędzia, odczytuje zasoby i korzysta z gotowych promptów. W tym przewodniku zbudujesz lokalny serwer w TypeScript SDK v2, zabezpieczysz operację zapisu i poznasz wymagania zdalnego wdrożenia przez Streamable HTTP.
Maciej Sala
Founder StriveLab
Modele językowe pokroju ChatGPT czy Claude potrafią być niesamowicie pożyteczne, ale nie mają pojęcia, co kryje się w Twojej wewnętrznej dokumentacji, bazie wiedzy czy FAQ. Zapytane o szczegóły, których nie znają, zaczynają halucynować . RAG rozwiązuje ten problem: zanim model odpowie, podsuwasz mu właściwe fragmenty Twoich danych i mówisz wprost: „Odpowiadaj na podstawie tego, a nie z pamięci". Ten artykuł pokazuje, jak zbudować taki system w Next.js — krok po kroku, od cięcia tekstu po gotowy chat.
Maciej Sala
Founder StriveLab
Wyobraźmy sobie sklep internetowy. Ma on 500 produktów i potrzebuje 500 unikalnych opisów, 500 meta descriptions, 500 alt tekstów do zdjęć i 500 zestawów tagów SEO. Ręczne pisanie brzmi koszmarnie, ale jest możliwość, że AI wygeneruje je w minuty. Jak zrobić, żeby automatyzacja zadziała, nie utracić jakości i by Google je "zaakceptował"?