Upload, PDF i CSV w Next.js. Dowiedz się, jak okiełznać przetwarzanie plików w App Routerze i na co uważać przy wdrożeniu.
Maciej Sala
Founder StriveLab
7 min czytaniaOpublikowano 11 kwietnia 2026 (Aktualizacja 16 lipca 2026)
Upload plików do storage: usługa zarządzana czy własny bucket?
Miej na uwadze, że podgląd pliku w przeglądarce to jeszcze nie upload, dlatego na produkcji potrzebujesz storage, limitów typu i rozmiaru, kontroli dostępu i bezpiecznego zapisu metadanych po przesłaniu. Tu rozwidlają się dwie sensowne drogi:
Podejście
Kiedy ma sens
Za co odpowiadasz
Zarządzana usługa uploadu, np. UploadThing
Avatar, galeria lub dokumenty, gdy liczy się szybki start
Autoryzacja endpointu, reguły typów, zapis URL i usuwanie danych
Własny S3/R2 z
Wymagana pełna kontrola bucketa, retencji, kosztów albo przetwarzania
IAM, podpisy, CORS, lifecycle, skanowanie i monitoring
W wariancie zarządzanym reguły uploadu trzymaj po stronie serwera. Przykładowo file router może przyjąć wyłącznie obraz użytkownika po sprawdzeniu sesji:
Code
yarn add uploadthing @uploadthing/react
Code
// app/api/uploadthing/core.tsimport { createUploadthing, type FileRouter } from 'uploadthing/next'import { auth } from '@/auth'import { db } from '@/lib/db'const f = createUploadthing()export const fileRouter = { avatar: f({ image: { maxFileSize: '4MB', maxFileCount: 1 } }) .middleware(async () => { const session = await auth() if (!session?.user?.id) throw new Error('Unauthorized') return { userId: session.user.id } }) .onUploadComplete(async ({ metadata, file }) => { await db.user.update({ where: { id: metadata.userId }, data: { avatarUrl: file.ufsUrl }, }) }),} satisfies FileRouter
Nie traktuj samej walidacji w komponencie React jako jakiejkolwiek form zabezpieczenia. Typ, rozmiar i uprawnienia muszą być weryfikowane po stronie serwera, a przy dokumentach od użytkowników rozważ także skanowanie plików oraz politykę usuwania. Endpoint uploadu warto dodatkowo objąć rate limitingiem, bo masowe wrzucanie plików to jeden z prostszych sposobów na wygenerowanie kosztów po stronie storage. Powyższy router zostawia domyślny, publiczny dostęp do avatara; dla dokumentów ustaw prywatny ACL i wydawaj osobny, krótko żyjący adres do odczytu po sprawdzeniu uprawnień.
Pamiętaj przy tym, że file.type i rozszerzenie nazwy to deklaracja klienta, którą w banalny sposób można podrobić, czyli .exe przemianowany na .png przejdzie każdą walidację opartą o te pola. Realne zabezpieczenie to sprawdzenie sygnatury pliku (magic bytes) na podstawie jego faktycznej zawartości:
Code
// lib/validate-upload.tsimport { fileTypeFromBuffer } from 'file-type'const ALLOWED = new Set(['image/jpeg', 'image/png', 'image/webp'])export async function assertAllowedImage(buffer: Buffer) { const detected = await fileTypeFromBuffer(buffer) if (!detected || !ALLOWED.has(detected.mime)) { throw new Error('Niedozwolony typ pliku') } return detected.mime}
W modelu z presigned URL plik trafia bezpośrednio do storage'u, więc weryfikację musisz przeprowadzić po fakcie, czyli w callbacku/webhooku od dostawcy chmury lub asynchronicznie po przesłaniu obiektu. Najważniejsze jest, aby plik po wgraniu domyślnie znajdował się w 'kwarantannie' i gdy weryfikacja zakończy się niepowodzeniem, wyleciał i nie trafił do obiegu aplikacji.
Presigned URL: bezpieczny flow dla większych plików
Przy większych plikach nie przepychaj danych przez Route Handler ani Server Action. Serwer powinien tylko sprawdzić sesję, typ, rozmiar i uprawnienia, a następnie wystawić krótko żyjący adres do zapisu w storage:
W S3 helper createPresignedUploadPost musi tworzyć presigned POST policy z warunkiem content-length-range od 1 do MAX_SIZE. Przeglądarka wysyła wtedy FormData z polami fields zwróconymi przez endpoint, a plik dodaje jako ostatnie pole. Nie zastępuj tego zwykłym presigned PUT: wartość size przesłana przed podpisaniem jest tylko deklaracją klienta i sama nie ogranicza faktycznego rozmiaru obiektu. Przy R2 lub innym storage sprawdź, jaki odpowiednik limitu wymusza jego mechanizm uploadu; jeśli go nie ma, ogranicz upload przed storage i usuń obiekty przekraczające limit w procesie kontrolnym.
Po uploadzie zapisz metadane dopiero wtedy, gdy plik przejdzie sprawdzanie po stronie serwera. Sprawdzanie dotyczy sygnatury pliku, limitów wymiaru obrazu, ewentualne skanowanie antywirusowe i reguły retencji. W sytuacji, w której pliki nie mają być publiczne, trzymaj bucket prywatny i wystawiaj osobne, krótko żyjące signed URLs do pobierania. Publiczny URL zapisany w bazie jest prosty, ale później trudno cofnąć dostęp bez przenoszenia lub usuwania obiektu.
Generowanie PDF z danych w Next.js
PDF generujesz na serwerze, w Route Handlerze działającym na runtime Node. @react-pdf/renderer korzysta z canvas i fontkit, więc nie uruchomisz go na Edge Runtime — to jeden z klasycznych przypadków, w których zostajesz przy Node, a nie przenosisz logiki na edge. Miej też świadomość, że biblioteka jest stosunkowo ciężka: przy własnych fontach i większych szablonach bundle rośnie, a pierwszy cold start funkcji potrafi być zauważalny. Dla często generowanych dokumentów rozważ cache'owanie gotowego PDF-a albo generowanie w tle.
PDF po stronie serwera z @react-pdf/renderer
Code
yarn add @react-pdf/renderer
Zanim ułożysz szablon, załatw font — domyślna Helvetica nie zawiera polskich znaków, więc bez własnego fontu „ą" i „ż" po prostu znikną z faktury. Przy rejestracji czekają dwie pułapki: react-pdf wspiera wyłącznie TTF i WOFF (plik WOFF2 z Google Fonts nie zadziała), a pogrubienie wymaga zarejestrowania osobnego pliku bold — samo fontWeight: 'bold' w stylach nic nie pogrubi, jeśli wariant nie istnieje.
// app/api/invoices/[id]/pdf/route.tsx — JSX w handlerze wymaga rozszerzenia .tsximport { renderToBuffer } from '@react-pdf/renderer'import { InvoicePDF } from '@/lib/pdf/invoice-template'import { db } from '@/lib/db'import { auth } from '@/auth'export const runtime = 'nodejs'export async function GET( request: Request, { params }: { params: Promise<{ id: string }> },) { const session = await auth() if (!session?.user?.id) return new Response('Unauthorized', { status: 401 }) const { id } = await params // Własność w zapytaniu, nie tylko "czy zalogowany" — inaczej każdy // zalogowany użytkownik pobierze cudzą fakturę, zgadując id (IDOR). const invoice = await db.invoice.findFirst({ where: { id, ownerId: session.user.id }, include: { items: true, client: true }, }) if (!invoice) return new Response('Not found', { status: 404 }) const safeNumber = invoice.number.replace(/[^a-zA-Z0-9-_]/g, '-') const pdfBuffer = await renderToBuffer( <InvoicePDF invoice={{ number: invoice.number, date: invoice.date.toLocaleDateString('pl-PL'), clientName: invoice.client.name, clientAddress: invoice.client.address, items: invoice.items, }} />, ) return new Response(pdfBuffer, { headers: { 'Content-Type': 'application/pdf', 'Content-Disposition': `attachment; filename="faktura-${safeNumber}.pdf"`, }, })}
Code
// Przycisk pobierania PDF — endpoint w tej samej aplikacji,// więc cookie sesji idzie automatycznie.<a href={`/api/invoices/${invoice.id}/pdf`} download className="text-blue-600 hover:underline"> Pobierz PDF</a>
Import i parsowanie CSV w aplikacji Next.js
Do parsowania CSV w przeglądarce najwygodniej użyć biblioteki , która obsługuje nagłówki i kodowanie. Poniższy komponent jest przeznaczony dla małych importów z podglądem; duże pliki obsłuż osobnym wariantem strumieniowym.
Jedna istotna sprawa. Polski Excel zapisuje „CSV (rozdzielany średnikami)" w kodowaniu Windows-1250, a nie UTF-8, co oznacza, że polskie znaki wyjdą wtedy jako krzaki. Separator PapaParse wykryje to sam, ale kodowanie musisz wskazać jawnie, np. <CSVImport encoding="windows-1250" />, jeśli import ma przyjmować pliki prosto z Excela. Możesz też po prostu poprosić użytkowników o eksport w wariancie „CSV UTF-8".
Przy importach liczących dziesiątki tysięcy wierszy użyj step albo chunk, a nie complete z całym results.data. Waliduj kolejne wiersze, zbieraj błędy z limitem ich liczby i wysyłaj poprawne rekordy do dedykowanego endpointu w paczkach. Sam worker: true utrzyma responsywność interfejsu, ale nie rozwiąże problemu pamięci, jeśli mimo to zatrzymasz wszystkie wiersze w tablicy.
Server Action do przetwarzania zaimportowanych danych
Code
// actions/import-products.ts'use server'import { z } from 'zod'import { db } from '@/lib/db'import { auth } from '@/auth'import { revalidatePath } from 'next/cache'const productRowSchema = z.object({ name: z.string().min(1), price: z.coerce.number().positive(), category: z.string().min(1), sku: z.string().optional(),})const MAX_ROWS = 5_000export async function importProducts(rows: Record<string, string>[]) { const session = await auth() if (!session?.user?.id) { return { imported: 0, errors: ['Brak autoryzacji'] } } // Server Action przyjmuje to, co wyśle klient — nie ufaj rozmiarowi. // Odetnij oczywiście za duże importy, zanim zaczniesz pisać do bazy. if (rows.length > MAX_ROWS) { return { imported: 0, errors: [`Limit ${MAX_ROWS} wierszy na import`] } } const results = { imported: 0, errors: [] as string[] } for (const [index, row] of rows.entries()) { const parsed = productRowSchema.safeParse(row) if (!parsed.success) { results.errors.push( `Wiersz ${index + 1}: ${parsed.error.issues[0].message}`, ) continue } try { await db.product.create({ data: { ...parsed.data, ownerId: session.user.id, }, }) results.imported++ } catch (error) { results.errors.push(`Wiersz ${index + 1}: duplikat lub błąd bazy`) } } revalidatePath('/dashboard/products') return results}
Powyższy przykład tworzy rekordy wiersz po wierszu, żeby zwrócić precyzyjny błąd przy każdym wadliwym wierszu. To czytelne, ale przy dużych importach oznacza N zapytań do bazy. W produkcji waliduj wszystkie wiersze Zodem, a poprawne zapisuj paczkami przez createMany (np. po tysiąc rekordów) — dostaniesz znacznie mniej round-tripów do bazy, zachowując raport błędów dla wierszy odrzuconych na etapie walidacji. Jeżeli aplikacja ma organizacje lub role, sprawdzaj też uprawnienie do importu w konkretnym kontekście, a nie tylko sam fakt zalogowania.
Jest jeszcze limit, o który rozbija się większość pierwszych wdrożeń: Server Actions przyjmują domyślnie body do 1 MB. Kilka tysięcy wierszy z dłuższymi polami tekstowymi potrafi go przekroczyć — i zamiast raportu błędów dostaniesz odrzucone żądanie. Limit podniesiesz przez experimental.serverActions.bodySizeLimit w next.config, ale przy naprawdę dużych importach zdrowszy jest wzorzec wysyłki w paczkach po tysiąc wierszy. Analogiczny sufit obowiązuje przy uploadzie przez Route Handler na platformach serverless — na Vercel request body ma twardy limit 4,5 MB (błąd 413), więc duże pliki kieruj bezpośrednio do storage przez presigned URL, z pominięciem Twojego serwera. Sama walidacja w Server Action rządzi się tymi samymi regułami co każda inna mutacja, które rozwijam w artykule o testowaniu Server Actions.
To zabezpieczenie nie jest kosmetyką. Jeżeli eksportujesz dane wpisane przez użytkowników, komórka zaczynająca się od =, +, -, @ albo znaku tabulacji może zostać potraktowana przez arkusz kalkulacyjny jak formuła. Dodanie apostrofu przed taką wartością neutralizuje CSV formula injection, a zwykłe teksty nadal otwierają się poprawnie.
Code
// Przycisk eksportu<button onClick={() => generateCSV(products, 'produkty-export')}> Eksportuj do CSV</button>
Podgląd obrazów przed uploadem pliku
URL.createObjectURL daje natychmiastowy podgląd bez wysyłania pliku na serwer — kluczowe jest tylko zwolnienie URL-a przez URL.revokeObjectURL po usunięciu podglądu, żeby nie wyciekała pamięć. Walidacja file.type i rozmiaru w komponencie jest tu wyłącznie warstwą UX. Po uploadzie serwer nadal powinien sprawdzić sygnaturę pliku, limit wymiarów obrazu i ewentualnie usunąć EXIF, bo mały plik może mieć ogromne wymiary po dekompresji. Gdy obraz trafi już na serwer, o jego dalszej optymalizacji (formaty, rozmiary, CDN) piszę osobno w poradniku o obrazach w Next.js.
Code
// components/image-preview-upload.tsx'use client'import { useState, useCallback, useEffect, useRef } from 'react'import Image from 'next/image'interface ImageFile { file: File preview: string}export function ImagePreviewUpload({ maxFiles = 5, maxSizeMB = 5,}: { maxFiles?: number maxSizeMB?: number}) { const [images, setImages] = useState<ImageFile[]>([]) const [error, setError] = useState<string | null>(null) // Trzymamy aktualną listę podglądów w refie, żeby cleanup przy // odmontowaniu komponentu miał dostęp do najnowszego stanu. const imagesRef = useRef<ImageFile[]>([]) imagesRef.current = images // Zwolnij wszystkie aktywne URL-e, gdy komponent znika ze strony. useEffect(() => { return () => { imagesRef.current.forEach((img) => URL.revokeObjectURL(img.preview)) } }, []) const handleFiles = useCallback( (files: FileList | null) => { if (!files) return setError(null) const newImages: ImageFile[] = [] for (const file of Array.from(files)) { // Walidacja typu if (!file.type.startsWith('image/')) { setError('Akceptowane są tylko obrazy (JPG, PNG, WebP)') continue } // Walidacja rozmiaru if (file.size > maxSizeMB * 1024 * 1024) { setError(`Maksymalny rozmiar pliku: ${maxSizeMB} MB`) continue } newImages.push({ file, preview: URL.createObjectURL(file), }) } setImages((prev) => { const combined = [...prev, ...newImages] // Jeśli limit ucina nadmiarowe pliki, zwolnij ich URL-e — // inaczej zostają w pamięci bez żadnego podglądu w UI. if (combined.length > maxFiles) { combined .slice(maxFiles) .forEach((img) => URL.revokeObjectURL(img.preview)) } return combined.slice(0, maxFiles) }) }, [maxFiles, maxSizeMB], ) function removeImage(index: number) { setImages((prev) => { const image = prev[index] if (image) URL.revokeObjectURL(image.preview) return prev.filter((_, i) => i !== index) }) } return ( <div className="space-y-4"> {/* Drop zone */} <div onDrop={(e) => { e.preventDefault() handleFiles(e.dataTransfer.files) }} onDragOver={(e) => e.preventDefault()} className="rounded-xl border-2 border-dashed p-6 text-center" > <input type="file" accept="image/*" multiple onChange={(e) => handleFiles(e.target.files)} className="hidden" id="image-upload" /> <label htmlFor="image-upload" className="cursor-pointer"> <p className="text-gray-600">Przeciągnij zdjęcia lub kliknij</p> <p className="mt-1 text-sm text-gray-400"> Max {maxFiles} plików, do {maxSizeMB} MB każdy </p> </label> </div> {error && <p className="text-sm text-red-600">{error}</p>} {/* Podgląd */} {images.length > 0 && ( <div className="grid grid-cols-5 gap-3"> {images.map((img, i) => ( <div key={i} className="group relative aspect-square"> <Image src={img.preview} alt={`Podgląd ${i + 1}`} fill // blob: URL nie przejdzie przez optymalizator next/image — // bez unoptimized dostaniesz błąd zamiast podglądu unoptimized className="rounded-lg object-cover" /> <button type="button" onClick={() => removeImage(i)} className="absolute -top-2 -right-2 h-6 w-6 rounded-full bg-red-500 text-sm text-white opacity-0 transition-opacity group-hover:opacity-100" > ✕ </button> <p className="absolute bottom-1 left-1 rounded bg-black/50 px-1 text-xs text-white"> {(img.file.size / 1024 / 1024).toFixed(1)} MB </p> </div> ))} </div> )} </div> )}
Elastyczne i wydajne narzędzia dla biznesu, które dotrzymają kroku Twojemu rozwojowi.
Najprościej biblioteką xlsx (SheetJS), która działa zarówno po stronie klienta, jak i serwera. W odróżnieniu od CSV format XLSX obsługuje formatowanie komórek, wiele arkuszy w jednym pliku oraz formuły, więc sprawdza się, gdy odbiorca oczekuje gotowego raportu, a nie surowych danych. Gdyby wystarczył Ci prosty zrzut danych do zaimportowania gdzie indziej to zostań przy CSV.
Czy @react-pdf/renderer działa na Edge Runtime?
Nie. @react-pdf/renderer wymaga środowiska Node (korzysta m.in. z canvas i fontkit), więc nie uruchomisz go na Edge Runtime. Generuj PDF w Route Handlerach działających na runtime Node, co i tak jest naturalnym wyborem dla zadania, które jest czysto serwerowe i potrafi być cięższe obliczeniowo. Edge zostaw dla lekkich i szybkich operacji blisko użytkownika.
Jak obsłużyć bardzo duże pliki CSV (100 tys.+ wierszy)?
Nie wczytuj całego pliku do pamięci. papaparse obsługuje parsowanie strumieniowe. Musisz podać callback step, który jest wywoływany dla każdego wiersza w miarę czytania pliku, dzięki czemu pamięć pozostaje stała niezależnie od rozmiaru. Przetworzone dane wysyłaj do serwera w paczkach (np. po tysiąc wierszy), zamiast jednym dużym żądaniem, co będzie chronić zarówno przeglądarkę, jak i serwer przed przeciążeniem. Paczki muszą się też mieścić w limicie body Server Actions (domyślnie 1 MB, konfigurowalny przez serverActions.bodySizeLimit).
Dlaczego walidacja pliku w przeglądarce nie wystarcza?
Bo wszystko, co dzieje się w przeglądarce, można obejść. Sprawdzenie typu i rozmiaru w React nie jest zabezpieczeniem, tyle że poprawia UX. Złośliwe żądanie może trafić bezpośrednio do endpointu uploadu z pominięciem Twojego interfejsu. Weryfikację typu, rozmiaru i uprawnień plików przeprowadzaj zawsze na serwerze. Nie polegaj na właściwości file.type ani rozszerzeniu z przeglądarki, gdyż są one łatwe do sfałszowania. Zamiast tego weryfikuj typ pliku na podstawie jego sygnatury (magic bytes), np. przy użyciu biblioteki file-type. Ponadto, dla każdego pliku pochodzącego od użytkownika, zadbaj o skanowanie zawartości oraz ustal przejrzyste zasady przechowywania i retencji danych.
Po co dodawać BOM do eksportowanego pliku CSV?
Żeby Excel poprawnie odczytał polskie znaki. Bez znacznika BOM (Byte Order Mark) na początku pliku Excel często interpretuje CSV w niewłaściwym kodowaniu i zamiast ą, ć czy ż pokazuje „krzaki”. Dopisanie \uFEFF na początku zawartości pliku rozwiązuje problem, nie psując odczytu w innych narzędziach. To drobny detal, który oszczędza sporo frustracji użytkownikom otwierającym eksport w Excelu.
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.
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
Founder StriveLab
App Router daje Ci dwa sposoby na uruchomienie kodu po stronie serwera, poprzez Route Handlers i Server Actions . Może wyglądają podobnie, ponieważ oba działają na serwerze, sięgają do bazy i do zmiennych środowiskowych, ale każdy z nich rozwiązuje zupełnie inny problem. Użycie jednego tam, gdzie pasuje drugi, nie jest odpowiednim rozwiązaniem, ponieważ zły wybór odbija się potem na architekturze.
Maciej Sala
Founder StriveLab
Edge Runtime obiecuje błyskawiczne działanie kodu tuż przy użytkowniku. To ultralekkie środowisko, oparte na V8 Isolates i rozproszone globalnie, eliminuje cold start , bo nie wymaga rozgrzewania ciężkich maszyn wirtualnych. Przez lata wydawało się to jedynym słusznym kierunkiem, ale dzisiaj jednak, w Next.js 16, automatyczny wybór Edge może okazać się strategicznym błędem.