Przejdź do treści

Jak bezpiecznie przetwarzać pliki w Next.js?

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ścieKiedy ma sensZa co odpowiadasz
Zarządzana usługa uploadu, np. UploadThingAvatar, galeria lub dokumenty, gdy liczy się szybki startAutoryzacja endpointu, reguły typów, zapis URL i usuwanie danych
Własny S3/R2 z Wymagana pełna kontrola bucketa, retencji, kosztów albo przetwarzaniaIAM, 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.ts
import { 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.ts
import { 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:

Code
// app/api/uploads/presign/route.ts
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { auth } from '@/auth'
import { createPresignedUploadPost } from '@/lib/storage'
 
const MAX_SIZE = 10 * 1024 * 1024
const presignSchema = z.object({
  filename: z.string().min(1).max(255),
  contentType: z.enum(['image/jpeg', 'image/png', 'application/pdf']),
  size: z.number().int().positive().max(MAX_SIZE),
})
 
export async function POST(request: Request) {
  const session = await auth()
  if (!session?.user?.id) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }
 
  const body = await request.json().catch(() => null)
  const parsed = presignSchema.safeParse(body)
  if (!parsed.success) {
    return NextResponse.json({ error: 'Invalid file' }, { status: 400 })
  }
 
  const { filename, contentType } = parsed.data
  const extension = filename.split('.').pop()?.toLowerCase() ?? 'bin'
  const key = `uploads/${session.user.id}/${crypto.randomUUID()}.${extension}`
  const upload = await createPresignedUploadPost({
    key,
    contentType,
    maxSize: MAX_SIZE,
    expiresIn: 300,
  })
 
  return NextResponse.json({
    key,
    uploadUrl: upload.url,
    fields: upload.fields,
  })
}

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.

Code
// lib/pdf/invoice-template.tsx
import path from 'node:path'
import {
  Document,
  Page,
  Text,
  View,
  StyleSheet,
  Font,
} from '@react-pdf/renderer'
 
// Tylko TTF i WOFF — WOFF2 nie jest wspierany.
// Bold to osobny plik; bez niego fontWeight: 'bold' nie zadziała.
Font.register({
  family: 'Inter',
  fonts: [
    { src: path.join(process.cwd(), 'assets/fonts/Inter-Regular.ttf') },
    {
      src: path.join(process.cwd(), 'assets/fonts/Inter-Bold.ttf'),
      fontWeight: 700,
    },
  ],
})
 
const styles = StyleSheet.create({
  page: { padding: 40, fontFamily: 'Inter', fontSize: 11 },
  header: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    marginBottom: 30,
  },
  title: { fontSize: 24, fontWeight: 'bold', color: '#1e3a5f' },
  table: { width: '100%', marginTop: 20 },
  tableRow: {
    flexDirection: 'row',
    borderBottomWidth: 1,
    borderColor: '#e5e7eb',
    paddingVertical: 8,
  },
  tableHeader: {
    flexDirection: 'row',
    borderBottomWidth: 2,
    borderColor: '#1e3a5f',
    paddingBottom: 8,
  },
  col1: { width: '50%' },
  col2: { width: '20%', textAlign: 'right' },
  col3: { width: '15%', textAlign: 'right' },
  col4: { width: '15%', textAlign: 'right', fontWeight: 'bold' },
  total: {
    flexDirection: 'row',
    justifyContent: 'flex-end',
    marginTop: 20,
    paddingTop: 10,
    borderTopWidth: 2,
    borderColor: '#1e3a5f',
  },
  totalLabel: { fontSize: 14, fontWeight: 'bold', marginRight: 20 },
  totalValue: { fontSize: 14, fontWeight: 'bold' },
})
 
interface InvoiceItem {
  name: string
  quantity: number
  unitPrice: number
}
 
interface InvoiceData {
  number: string
  date: string
  clientName: string
  clientAddress: string
  items: InvoiceItem[]
}
 
export function InvoicePDF({ invoice }: { invoice: InvoiceData }) {
  const total = invoice.items.reduce(
    (sum, item) => sum + item.quantity * item.unitPrice,
    0,
  )
 
  return (
    <Document>
      <Page size="A4" style={styles.page}>
        <View style={styles.header}>
          <View>
            <Text style={styles.title}>FAKTURA</Text>
            <Text>Nr: {invoice.number}</Text>
            <Text>Data: {invoice.date}</Text>
          </View>
          <View>
            <Text style={{ fontWeight: 'bold' }}>NazwaFirmy</Text>
            <Text>Jan Kowalski</Text>
            <Text>Kraków, Polska</Text>
          </View>
        </View>
 
        <View>
          <Text style={{ fontWeight: 'bold', marginBottom: 4 }}>Nabywca:</Text>
          <Text>{invoice.clientName}</Text>
          <Text>{invoice.clientAddress}</Text>
        </View>
 
        <View style={styles.table}>
          <View style={styles.tableHeader}>
            <Text style={styles.col1}>Opis</Text>
            <Text style={styles.col2}>Ilość</Text>
            <Text style={styles.col3}>Cena jedn.</Text>
            <Text style={styles.col4}>Wartość</Text>
          </View>
          {invoice.items.map((item, i) => (
            <View key={i} style={styles.tableRow}>
              <Text style={styles.col1}>{item.name}</Text>
              <Text style={styles.col2}>{item.quantity}</Text>
              <Text style={styles.col3}>{item.unitPrice.toFixed(2)} PLN</Text>
              <Text style={styles.col4}>
                {(item.quantity * item.unitPrice).toFixed(2)} PLN
              </Text>
            </View>
          ))}
        </View>
 
        <View style={styles.total}>
          <Text style={styles.totalLabel}>RAZEM:</Text>
          <Text style={styles.totalValue}>{total.toFixed(2)} PLN</Text>
        </View>
      </Page>
    </Document>
  )
}

Route Handler do generowania i pobrania PDF

Code
// app/api/invoices/[id]/pdf/route.tsx — JSX w handlerze wymaga rozszerzenia .tsx
import { 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.

Komponent importu CSV po stronie klienta

Code
// components/csv-import.tsx
'use client'
 
import { useState, useCallback } from 'react'
import Papa from 'papaparse'
 
interface CSVRow {
  [key: string]: string
}
 
interface CSVImportProps {
  onImport: (data: CSVRow[]) => void
  expectedColumns?: string[]
  encoding?: string
}
 
export function CSVImport({
  onImport,
  expectedColumns,
  encoding = 'UTF-8',
}: CSVImportProps) {
  const [preview, setPreview] = useState<CSVRow[] | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [fileName, setFileName] = useState<string>('')
 
  const handleFile = useCallback(
    (file: File) => {
      setError(null)
      setFileName(file.name)
 
      // Ten wariant przekazuje wszystkie wiersze do onImport, dlatego nie
      // nadaje się do dużych plików ani importu wysyłanego jednym żądaniem.
      if (file.size > 500 * 1024) {
        setError('Ten widok obsługuje pliki CSV do 500 KB')
        return
      }
 
      Papa.parse<CSVRow>(file, {
        header: true,
        skipEmptyLines: true,
        encoding,
        complete: (results) => {
          if (results.errors.length > 0) {
            setError(`Błąd parsowania: ${results.errors[0].message}`)
            return
          }
 
          // Walidacja kolumn
          if (expectedColumns) {
            const headers = Object.keys(results.data[0] || {})
            const missing = expectedColumns.filter(
              (col) => !headers.includes(col),
            )
            if (missing.length > 0) {
              setError(`Brakujące kolumny: ${missing.join(', ')}`)
              return
            }
          }
 
          setPreview(results.data.slice(0, 5)) // Pokaż 5 pierwszych wierszy
          onImport(results.data)
        },
      })
    },
    [onImport, expectedColumns],
  )
 
  return (
    <div className="space-y-4">
      <div
        onDrop={(e) => {
          e.preventDefault()
          const file = e.dataTransfer.files[0]
          if (file?.type === 'text/csv' || file?.name.endsWith('.csv')) {
            handleFile(file)
          } else {
            setError('Akceptowane są tylko pliki CSV')
          }
        }}
        onDragOver={(e) => e.preventDefault()}
        className="cursor-pointer rounded-xl border-2 border-dashed p-8 text-center hover:border-blue-400"
      >
        <input
          type="file"
          accept=".csv"
          onChange={(e) => {
            const file = e.target.files?.[0]
            if (file) handleFile(file)
          }}
          className="hidden"
          id="csv-upload"
        />
        <label htmlFor="csv-upload" className="cursor-pointer">
          {fileName ? (
            <p className="font-medium text-blue-600">{fileName}</p>
          ) : (
            <>
              <p className="text-gray-600">
                Przeciągnij plik CSV lub kliknij, żeby wybrać
              </p>
              {expectedColumns && (
                <p className="mt-1 text-sm text-gray-400">
                  Wymagane kolumny: {expectedColumns.join(', ')}
                </p>
              )}
            </>
          )}
        </label>
      </div>
 
      {error && <p className="text-sm text-red-600">{error}</p>}
 
      {preview && (
        <div className="overflow-x-auto">
          <p className="mb-2 text-sm text-gray-500">
            Podgląd (5 pierwszych wierszy):
          </p>
          <table className="w-full border text-sm">
            <thead className="bg-gray-50">
              <tr>
                {Object.keys(preview[0]).map((key) => (
                  <th key={key} className="border-b px-3 py-2 text-left">
                    {key}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {preview.map((row, i) => (
                <tr key={i} className="border-b">
                  {Object.values(row).map((val, j) => (
                    <td key={j} className="px-3 py-2">
                      {val}
                    </td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}
    </div>
  )
}

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_000
 
export 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.

Eksport danych do CSV z Next.js

Code
// lib/export-csv.ts
const FORMULA_PREFIX = /^[=+\-@\t\r]/
 
function escapeCSVCell(value: unknown) {
  const raw = String(value ?? '')
  const safe = FORMULA_PREFIX.test(raw) ? `'${raw}` : raw
  return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe
}
 
export function generateCSV(data: Record<string, unknown>[], filename: string) {
  if (data.length === 0) return
 
  const headers = Object.keys(data[0])
  const csvContent = [
    headers.join(','),
    ...data.map((row) => headers.map((h) => escapeCSVCell(row[h])).join(',')),
  ].join('\n')
 
  // BOM dla poprawnego kodowania polskich znaków w Excel
  const blob = new Blob(['\uFEFF' + csvContent], {
    type: 'text/csv;charset=utf-8;',
  })
  const url = URL.createObjectURL(blob)
 
  const link = document.createElement('a')
  link.href = url
  link.download = `${filename}.csv`
  link.click()
 
  URL.revokeObjectURL(url)
}

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.
Next.js

Często zadawane pytania

Jak wygenerować Excel (XLSX) zamiast CSV?

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.

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

Route Handlers czy Server Actions? Kiedy co wybrać

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

Maciej Sala

Founder StriveLab

CSRF, XSS, Injection: 5 luk bezpieczeństwa w Next.js

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.

Maciej Sala

Maciej Sala

Founder StriveLab