Przejdź do treści

Jak uruchamiać zadania cron (Scheduled Tasks) w Next.js?

Vercel Cron, Upstash QStash czy GitHub Actions? Sprawdź, jak bezpiecznie wdrażać harmonogramy zadań w aplikacjach Next.js.

Maciej Sala

Founder StriveLab

7 min czytaniaOpublikowano 11 kwietnia 2026 (Aktualizacja 17 lipca 2026)

Problem: Next.js nie ma wbudowanego crona

Z tej luki wychodzi się na trzy sposoby, każdy do innej skali problemu: Vercel Cron Jobs, Upstash QStash i GitHub Actions cron. Po kolei.

Vercel Cron Jobs w Next.js

Vercel pozwala definiować w vercel.json. Takie zadania wywołują Route Handlery o określonych porach.

Code
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "crons": [
    {
      "path": "/api/cron/daily-report",
      "schedule": "0 8 * * *"
    },
    {
      "path": "/api/cron/cleanup",
      "schedule": "0 3 * * 0"
    },
    {
      "path": "/api/cron/check-subscriptions",
      "schedule": "0 */6 * * *"
    }
  ]
}

Składnia cron: minute hour day-of-month month day-of-week

  • 0 8 * * *: codziennie o 8:00 UTC
  • 0 3 * * 0: co niedzielę o 3:00 UTC
  • 0 */6 * * *: co 6 godzin
  • */15 * * * *: co 15 minut

Vercel interpretuje harmonogram w UTC i wywołuje metodą GET wyłącznie aktualne wdrożenie produkcyjne. Nie używaj lokalnej godziny bez przeliczenia zmian czasu letniego. Cron nie podąża też za odpowiedziami 3xx, więc path musi prowadzić bezpośrednio do istniejącego handlera, bez przekierowania.

Route Handler jako adres zadania cron

Code
// app/api/cron/daily-report/route.ts
import { NextResponse } from 'next/server'
import { resend } from '@/lib/resend'
import { db } from '@/lib/db'
 
export const maxDuration = 60
 
export async function GET(request: Request) {
  // Weryfikacja: tylko Vercel Cron może wywołać ten adres.
  const authHeader = request.headers.get('authorization')
  const cronSecret = process.env.CRON_SECRET
 
  if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }
 
  const end = new Date()
  end.setUTCHours(0, 0, 0, 0)
  const start = new Date(end)
  start.setUTCDate(start.getUTCDate() - 1)
  const reportDate = start.toISOString().slice(0, 10)
 
  const stats = await db.order.aggregate({
    _count: true,
    _sum: { total: true },
    where: {
      createdAt: { gte: start, lt: end },
    },
  })
 
  const { error } = await resend.emails.send(
    {
      from: 'NazwaFirmy <raporty@example.com>',
      to: 'mike@example.com',
      subject: `Raport dzienny: ${reportDate}`,
      html: `
        <h2>Raport za ${reportDate}</h2>
        <p>Zamówienia: ${stats._count}</p>
        <p>Przychód: ${stats._sum.total || 0} PLN</p>
      `,
    },
    // Chroni wysyłkę przed podwójnym wywołaniem tego samego dnia.
    { idempotencyKey: `daily-report/${reportDate}` },
  )
 
  if (error) throw new Error(`Nie udało się wysłać raportu: ${error.message}`)
 
  return NextResponse.json({
    success: true,
    orders: stats._count,
    revenue: stats._sum.total,
  })
}

Zabezpieczenie zadania cron przez CRON_SECRET

Code
# .env.local / Vercel Environment Variables
CRON_SECRET=twoj-tajny-klucz-minimum-32-znaki

W panelu Vercel przejdź do zakładki Settings, a następnie Environment Variables i dodaj zmienną CRON_SECRET. Vercel będzie automatycznie przekazywał jej wartość w nagłówku Authorization przy każdym wywołaniu zadania cron.

maxDuration jest wskazówką dla platformy wdrożeniowej, a nie sposobem na nieograniczone wykonanie. Rzeczywisty limit zależy od hostingu i planu. Vercel nie ponawia nieudanych zadań cron, może jednak uruchomić ten sam termin więcej niż raz i rozpocząć kolejne wykonanie, zanim poprzednie zdąży się zakończyć. Klucz idempotencji u dostawcy poczty elektronicznej zabezpiecza wyłącznie tę pojedynczą operację, dlatego zmiany w bazie danych należy chronić transakcjami oraz unikalnymi kluczami biznesowymi. Dodatkowo, przed równoległym uruchomieniem zadań warto zastosować blokadę rozproszoną z czasem wygaśnięcia TTL.

Upstash QStash jako bezserwerowa kolejka wiadomości

to kolejka wiadomości z HTTP API. Sprawdza się przy zadaniach, które mają być dostarczone co najmniej raz ( ), mogą być opóźnione albo wymagają automatycznego ponawiania. „Co najmniej raz” oznacza, że odbiorca musi tolerować duplikaty.

Code
npm install @upstash/qstash
Code
QSTASH_TOKEN=...
QSTASH_CURRENT_SIGNING_KEY=...
QSTASH_NEXT_SIGNING_KEY=...
APP_URL=https://example.com

Zaplanowanie zadania w Upstash QStash

Code
// actions/schedule-report.ts
'use server'
 
import { Client } from '@upstash/qstash'
import { requireAdmin } from '@/lib/auth'
 
const qstashToken = process.env.QSTASH_TOKEN
const appUrl = process.env.APP_URL
 
if (!qstashToken || !appUrl) {
  throw new Error('Brakuje QSTASH_TOKEN lub APP_URL')
}
 
const qstash = new Client({
  token: qstashToken,
})
 
export async function scheduleWeeklyReport() {
  await requireAdmin()
 
  const jobId = crypto.randomUUID()
  const result = await qstash.publishJSON({
    url: `${appUrl}/api/cron/weekly-report`,
    body: { jobId, type: 'weekly' },
    retries: 3, // 3 ponowienia po pierwszym wywołaniu
    failureCallback: `${appUrl}/api/qstash/failure`,
  })
 
  return { jobId, messageId: result.messageId }
}
 
// Zaplanowanie z opóźnieniem
export async function scheduleReminder(userId: string, delayMinutes: number) {
  await requireAdmin()
 
  if (!userId || !Number.isInteger(delayMinutes) || delayMinutes < 1) {
    throw new Error('Nieprawidłowy użytkownik lub opóźnienie')
  }
 
  await qstash.publishJSON({
    url: `${appUrl}/api/reminders`,
    body: { userId, type: 'trial-ending' },
    delay: `${delayMinutes}m`,
  })
}

Zmienne QSTASH_TOKEN oraz APP_URL są wykorzystywane wyłącznie po stronie serwera, dlatego nie wymagają publicznego prefiksu. Warto też pamiętać, że Server Action nie stanowi pełnej granicy zaufania, ponieważ przed uruchomieniem kosztownego zadania należy każdorazowo zweryfikować sesję użytkownika, jego rolę oraz poprawność danych wejściowych. Ponadto parametr retries: 3 oznacza dokładnie trzy dodatkowe ponowienia po pierwszej próbie, co daje łącznie maksymalnie cztery próby dostarczenia zadania.

Odbiór zadania QStash w Route Handler

Code
// app/api/cron/weekly-report/route.ts
import { verifySignatureAppRouter } from '@upstash/qstash/nextjs'
 
type WeeklyReportJob = { jobId: string; type: 'weekly' }
 
function isWeeklyReportJob(value: unknown): value is WeeklyReportJob {
  return (
    typeof value === 'object' &&
    value !== null &&
    'jobId' in value &&
    typeof value.jobId === 'string' &&
    'type' in value &&
    value.type === 'weekly'
  )
}
 
async function handler(request: Request) {
  const body: unknown = await request.json()
 
  if (!isWeeklyReportJob(body)) {
    // Błąd trwały: nie zużywaj ponowień na wadliwy payload.
    return new Response('Invalid payload', {
      status: 489,
      headers: { 'Upstash-NonRetryable-Error': 'true' },
    })
  }
 
  const report = await generateWeeklyReport()
  await sendReportEmail(report, {
    idempotencyKey: `weekly-report/${body.jobId}`,
  })
 
  return new Response('OK')
}
 
// QStash automatycznie weryfikuje podpis. Żadne inne żądanie nie przejdzie.
export const POST = verifySignatureAppRouter(handler)

Wrapper wymaga trzech sekretów z konsoli Upstash: QSTASH_TOKEN do publikowania oraz QSTASH_CURRENT_SIGNING_KEY i QSTASH_NEXT_SIGNING_KEY do weryfikacji rotowanych podpisów. Weryfikacja podpisu potwierdza nadawcę, ale nie zapobiega ponownemu dostarczeniu poprawnie podpisanej wiadomości. jobId musi więc trafić do trwałego klucza idempotencji, unikalnego indeksu albo transakcji. Funkcja sendReportEmail z przykładu powinna przekazać ten klucz do dostawcy wiadomości.

Typowe zastosowania QStash w Next.js

Code
// 1. Wyślij email powitalny 30 minut po rejestracji
await qstash.publishJSON({
  url: 'https://api.example.com/emails/welcome',
  body: { userId: newUser.id },
  delay: '30m',
})
 
// 2. Sprawdź status płatności za 24h
await qstash.publishJSON({
  url: 'https://api.example.com/cron/check-payment',
  body: { orderId: order.id },
  delay: '1d',
})
 
// 3. Cykliczne czyszczenie przez QStash schedules
await qstash.schedules.create({
  destination: `${appUrl}/api/cron/cleanup`,
  scheduleId: 'daily-cleanup',
  cron: 'CRON_TZ=Europe/Warsaw 0 3 * * *',
  failureCallback: `${appUrl}/api/qstash/failure`,
})

QStash schedules mają maksymalną rozdzielczość jednej minuty; dostarczenie pojedynczej wiadomości możesz natomiast opóźnić o sekundy. Jawny scheduleId pozwala aktualizować istniejący harmonogram zamiast przypadkowo tworzyć kolejny przy każdym wdrożeniu. Po wyczerpaniu ponowień obsłuż failureCallback albo kontroluj kolejkę niedostarczonych zadań (Dead Letter Queue). Adres failureCallback jest również publiczny, dlatego zabezpiecz go przez verifySignatureAppRouter, tak jak adres odbierający zadanie.

GitHub Actions cron jako alternatywa

Dla prostych zadań GitHub Actions może uruchamiać workflow według harmonogramu:

Code
# .github/workflows/daily-report.yml
name: Daily Report
 
on:
  schedule:
    - cron: '0 8 * * *'
      timezone: 'Europe/Warsaw'
 
jobs:
  report:
    runs-on: ubuntu-latest
    steps:
      - name: Trigger report endpoint
        run: |
          curl --fail-with-body --silent --show-error \
            -H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
            https://example.com/api/cron/daily-report

Najkrótszy obsługiwany interwał wynosi 5 minut, ale GitHub nie daje nam jakichkolwiek gwarancji punktualnego startu. Przy dużym obciążeniu workflow może się opóźnić, a oczekujące uruchomienie nawet wypaść (szczególnie obciążony bywa początek godziny). Harmonogram działa tylko z domyślnej gałęzi, a w publicznym repozytorium zostaje automatycznie wyłączony po 60 dniach bez aktywności. Koszt zależy od widoczności repozytorium, użytego runnera i planu GitHub, więc nie zakładaj stałego limitu 2000 darmowych minut.

Czy cron powinien działać na Edge Runtime?

Edge Runtime nie jest harmonogramem, ale środowiskiem wykonania, więc nadal potrzebujesz Vercel Cron, QStash, GitHub Actions albo systemowego crona, który wywoła adres zadania. W Next.js domyślnym i rekomendowanym środowiskiem wykonania Route Handlera jest Node.js. Wybór export const runtime = 'edge' ma sens tylko wtedy, gdy wszystkie zależności zadania, w tym klient bazy i SDK dostawców, obsługują Edge. Nie omija też limitów czasu platformy.

after() z Next.js również nie zastępuje crona. Pozwala kontynuować pracę po wysłaniu odpowiedzi, ale callback nadal działa w ramach maxDuration tego samego wywołania i sam nie uruchomi się o wybranej godzinie. Podobnie setInterval lub timer utworzony przy starcie modułu jest niewiarygodny w środowisku bezserwerowym: instancja może zostać zamrożona albo usunięta w dowolnym momencie.

Idempotencja, blokady i monitoring zadań cron

Nie projektuj zadania na założeniu dokładnie jednego wykonania. Vercel może zarówno pominąć, jak i powtórzyć termin. QStash wykonuje skonfigurowaną liczbę ponowień, więc ta sama wiadomość może dotrzeć więcej niż raz. GitHub Actions może wystartować później lub pominąć oczekujące wykonanie przy dużym obciążeniu.

Idempotencję buduj na stabilnym kluczu biznesowym, np. daily-report/2026-07-16 albo ID konkretnego zdarzenia. Zapisuj go w bazie z unikalnym indeksem i używaj transakcji tam, gdzie skutek dotyczy danych. Dla zewnętrznych efektów ubocznych korzystaj z kluczy idempotencji dostawcy. Deduplication QStash ogranicza ponowne opublikowanie wiadomości w dziesięciominutowym oknie, ale nie zwalnia odbiorcy z obsługi powtórnego dostarczenia.

Jeżeli dwa wykonania nie mogą działać równolegle, zastosuj blokadę we współdzielonym magazynie z właścicielem i TTL dłuższym od typowego czasu zadania. Zwolnienie blokady umieść w finally, ale nie polegaj wyłącznie na ręcznym zwolnieniu, bo proces może zostać przerwany. Dla dużej liczby elementów zapisuj punkt kontrolny i przetwarzaj małe porcje, aby następne wykonanie mogło bezpiecznie wznowić pracę.

Monitoruj początek, koniec, czas trwania, liczbę przetworzonych rekordów, identyfikator uruchomienia i wynik. Zwracaj 2xx dopiero po rzeczywistym sukcesie; błąd przejściowy powinien dać 5xx, aby QStash wykonał ponowienie. Oprócz logów ustaw zewnętrzny sygnał kontrolny z maksymalnym dopuszczalnym opóźnieniem oraz alert na brak wykonania, bo brak żądania nie wygeneruje wyjątku w aplikacji.

Vercel Cron vs Upstash QStash vs GitHub Actions

MetodaMinimalny harmonogramPonowieniaSposób rozliczaniaNajlepsze zastosowanie
Vercel CronHobby: dobowy; Pro: minutaBrak automatycznychZużycie Vercel FunctionsProste zadania cykliczne
Upstash QStashMinuta; opóźnienie od sekundAutomatyczne, konfigurowalneWiadomości i wywołaniaPonowienia, kolejki i opóźnienia
GitHub Actions5 minutLogika workflow lub ręczny rerunMinuty runnera według planuRzadkie zadania repozytorium
Cron hostowany samodzielnieZwykle 1 minutaWłasna implementacjaSerwer i utrzymaniePełna kontrola
Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.
Next.js

Często zadawane pytania

Czy Vercel Cron Jobs są darmowe?

Są dostępne na wszystkich planach, ale wywołania zużywają limity Vercel Functions. Według dokumentacji zweryfikowanej 17 lipca 2026 plan Hobby pozwala na 100 definicji na projekt, najwyżej jedno uruchomienie dziennie i precyzję godzinową (do ±59 minut). Pro i Enterprise pozwalają na 100 definicji, interwał minutowy i precyzję minutową. Cennik oraz limity mogą się zmienić, więc sprawdź je ponownie przed wdrożeniem.

Jak monitorować zadania cron?

Na Vercel przejrzysz wywołania w logach, filtrując po ścieżce zadania cron (np. /api/cron/). QStash udostępnia własny dashboard z historią dostarczeń, opóźnień i ponowień, co jest szczególnie cenne przy zadaniach z ponawianiem. Dodaj alerty na błędy oraz zewnętrzny sygnał kontrolny, bo Vercel uruchamia crony bez gwarancji każdego terminu. Pominięte wywołanie może nie utworzyć żadnego logu funkcji, więc samo Sentry nie wykryje jego braku.

Co, jeśli zadanie cron trwa dłużej niż limit czasu funkcji?

Funkcje bezserwerowe mają limit czasu wykonania, który zależy od planu, więc długie zadania trzeba rozbić. Podziel pracę na mniejsze, idempotentne kroki w QStash lub Upstash Workflow albo przenieś ją do trwałego procesu roboczego. Samo after() nie omija limitu czasu. Działa tylko do maxDuration danej funkcji. QStash również ma planowy limit czasu odpowiedzi pojedynczego adresu.

Dlaczego trzeba zabezpieczać adres crona tokenem?

Bo Route Handler crona to zwykły, publicznie dostępny adres URL. Bez weryfikacji każdy w internecie mógłby go wywołać i np. uruchomić masową wysyłkę maili albo czyszczenie danych poza harmonogramem. Vercel rozwiązuje to, dołączając CRON_SECRET w nagłówku autoryzacji, który sprawdzasz na wejściu; QStash używa podpisu kryptograficznego weryfikowanego przez verifySignatureAppRouter. W obu przypadkach żądanie bez poprawnego sekretu jest odrzucane. Sprawdź też, czy zmienna środowiskowa istnieje, bo porównanie z Bearer undefined nie jest zabezpieczeniem.

Vercel Cron czy QStash, co wybrać?

Vercel Cron jest najprostszy dla zadań czysto cyklicznych uruchamianych o stałych porach, takich jak raporty lub czyszczenie danych, i nie wymaga dodatkowej usługi, jeśli i tak hostujesz na Vercel. QStash wybierz, gdy potrzebujesz opóźnień („wyślij za 30 minut"), automatycznego ponawiania po błędzie albo kolejkowania zadań wyzwalanych zdarzeniem, a nie tylko zegarem. W wielu projektach oba współistnieją: Vercel Cron do harmonogramu, QStash do zadań opóźnionych i zawodnych.

Dlaczego zadanie cron musi być idempotentne?

Harmonogram może uruchomić tę samą pracę więcej niż raz, a kolejne wykonanie może zacząć się przed zakończeniem poprzedniego. Vercel dokumentuje zarówno możliwe duplikaty, jak i pominięte wywołania, a QStash gwarantuje dostarczenie co najmniej raz. Używaj trwałych kluczy idempotencji, unikalnych ograniczeń w bazie i blokad z terminem wygaśnięcia. Nie zakładaj dokładnie jednego wykonania.

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 rozwijam własne projekty.

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

Skontaktuj się ze mną

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
Backend dla frontendowca: Cache, deployment, security

To trzecia część mojej małej serii „Backend dla frontendowca” i po fundamentach API oraz tematach real-time, webhooków i uwierzytelniania zostaje warstwa, która decyduje o tym, czy aplikacja wytrzyma prawdziwe użytkowanie: cache , kolejki, pliki, deployment, monitoring i bezpieczeństwo .

Maciej Sala

Maciej Sala

Founder StriveLab

Rate limiting na Edge w Next.js z Upstash

Jeden bot, jedna pętla z błędem albo jeden zdeterminowany atakujący — i Twoje API dostaje tysiące żądań na minutę. Serwer się dławi, rachunek za infrastrukturę rośnie, a normalni użytkownicy widzą błędy. Rate limiting ogranicza liczbę żądań z jednego źródła w oknie czasu. Pokazuję, jak wdrożyć go w Next.js z Upstash Redis — w Proxy, Route Handlerach i Server Actions, z trzema algorytmami i limitami per endpoint. W Next.js 16 proxy.ts działa jednak w Node.js, więc „na Edge” dotyczy Route Handlera z Edge Runtime albo infrastruktury dostawcy, nie samej konwencji Proxy.

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