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 — 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.
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
Przekonanie, że wbudowane w Reacta zabezpieczenia przed XSS w pełni chronią aplikację, to niebezpieczny mit. Współczesny Next.js to nie tylko warstwa prezentacyjna, ale pełnoprawne środowisko serwerowe z Server Actions, Route Handlerami, Server Components i bezpośrednim dostępem do nagłówków czy ciasteczek. Każdy z tych punktów styku staje się potencjalnym wektorem ataku, jeśli traktuje się go z taką samą beztroską jak zwykły komponent UI. Oto pięć najczęstszych luk bezpieczeństwa w architekturze Next.js oraz konkretne sposoby na ich wyeliminowanie.