Przejdź do treści

Jak bezpiecznie przetwarzać pliki w Next.js?

Bezpieczny upload, PDF i CSV w Next.js. Zobacz, jak wdrożyć kwarantannę plików, walidację importu, ochronę eksportu CSV i kontrolę dostępu do dokumentów.

Maciej Sala

Founder StriveLab

9 min czytaniaAktualizacja

Przykłady zakładają App Router, TypeScript i istniejącą warstwę sesji oraz bazy danych. Importy @/auth i @/lib/db reprezentują Twoją aplikację. Schematy tabel, uprawnienia organizacji i politykę storage musisz dopasować do jej modelu danych.

Upload plików do storage: usługa zarządzana czy własny bucket?

PodejścieKiedy ma sensCo pozostaje po Twojej stronie
Usługa zarządzana, np. UploadThingSzybkie wdrożenie uploadu i integracji z ReactUprawnienia, prywatność, weryfikacja zawartości, retencja i koszty
Własny S3 lub R2Własne reguły przechowywania i przetwarzaniaDodatkowo IAM, podpisy, CORS, obsługa zdarzeń i monitoring

Odebranie pliku nie oznacza dopuszczenia go do obiegu. W UploadThing callback onUploadComplete powinien uruchamiać proces weryfikacji i zapisywać identyfikator oczekującego pliku. Nie przypisuj w nim od razu publicznego URL-a jako avatara, jeśli zawartość nie została sprawdzona. Wybierz prywatny dostęp zgodnie z dokumentacją UploadThing, a publiczny obraz twórz dopiero po kontroli i ponownym kodowaniu.

Dla obu wariantów ogranicz liczbę uploadów i łączną przestrzeń użytkownika. Sam limit rozmiaru jednego obiektu nie chroni przed wysłaniem tysięcy plików. Sposób ograniczania żądań opisuję w poradniku o rate limitingu w Next.js.

Kwarantanna, weryfikacja i udostępnienie

Proces podziel na stany pending, processing, ready oraz rejected. To kontrakt dla implementacji, obejmujący endpoint podpisujący, worker i endpoint pobierania.

  1. Serwer tworzy rekord uploadu. Po sprawdzeniu sesji, uprawnień i limitów zapisuje właściciela, losowy klucz kwarantanny, dozwolony format i termin ważności. Klucz pochodzi z serwera. Oryginalna nazwa jest wyłącznie metadanymi.
  2. Przeglądarka wysyła plik do prywatnej kwarantanny. Polityka storage zabrania publicznego odczytu, a podpis pozwala zapisać tylko wskazany obiekt. Endpointy aplikacji nie wydają adresów pobrania dla stanu pending.
  3. Worker wiąże kontrolę z konkretną zawartością. Sprawdza rekord uploadu i pobiera konkretną wersję obiektu albo tworzy prywatną, niezmienną kopię do kontroli. Waliduje rzeczywisty rozmiar, format i limity parsera. Zdarzenia storage mogą się powtarzać, więc przejęcie zadania musi być atomowe, a obsługa idempotentna.
  4. Poprawny wynik trafia pod nowy klucz w obszarze docelowym, do którego klient nie ma prawa zapisu. Worker publikuje dokładnie sprawdzone bajty lub wynik bezpiecznego przetworzenia, a następnie zapisuje ready i klucz wyniku. Nie kopiuje ponownie bieżącego obiektu z kwarantanny, który mógł się zmienić podczas kontroli.
  5. Błąd kontroli oznacza rejected; błąd infrastruktury pozostawia plik niedostępny do czasu ponowienia. Reguły retencji usuwają odrzucone i porzucone uploady oraz osierocone wyniki. Pobieranie prywatnego pliku wymaga sprawdzenia właściciela i stanu ready przed wydaniem krótkiego signed URL-a.

Presigned URL w S3 można używać wielokrotnie do wygaśnięcia, a zapis pod istniejącym kluczem może zastąpić obiekt. Losowa nazwa nie eliminuje tego ryzyka. Rozdzielenie obiektu przyjmującego upload od zweryfikowanego wyniku zapobiega podmianie udostępnionej zawartości starym podpisem.

Presigned POST w S3 i limit rozmiaru

W S3 formularzowy POST pozwala wymusić zakres rozmiaru w podpisanej polityce. Poniższy helper wywołuj wyłącznie po autoryzacji i utworzeniu rekordu pending; bucket kwarantanny musi mieć zablokowany publiczny dostęp. Dane uwierzytelniające pozostają na serwerze i mają uprawnienia ograniczone do potrzebnego prefiksu.

Code
yarn add @aws-sdk/client-s3 @aws-sdk/s3-presigned-post
Code
// lib/storage/create-upload.ts
import 'server-only'
import { S3Client } from '@aws-sdk/client-s3'
import { createPresignedPost } from '@aws-sdk/s3-presigned-post'
 
const s3 = new S3Client({ region: process.env.AWS_REGION })
const MAX_BYTES = 10 * 1024 * 1024
 
type UploadType = 'image/jpeg' | 'image/png' | 'application/pdf'
 
export async function createUpload(uploadId: string, contentType: UploadType) {
  // uploadId jest identyfikatorem utworzonym na serwerze.
  return createPresignedPost(s3, {
    Bucket: process.env.UPLOAD_QUARANTINE_BUCKET!,
    Key: `pending/${uploadId}`,
    Expires: 300,
    Fields: { 'Content-Type': contentType },
    Conditions: [
      ['content-length-range', 1, MAX_BYTES],
      ['eq', '$Content-Type', contentType],
    ],
  })
}

Przeglądarka wysyła FormData do zwróconego url, dodając wszystkie fields, a plik jako ostatnie pole. Zgodność pola Content-Type z podpisem nadal nie dowodzi zgodności zawartości. Endpoint wydający podpis musi walidować żądanie w runtime oraz stosować ochronę przed CSRF, jeśli korzysta z sesji cookie. CORS storage ogranicz do potrzebnych originów i metod; nie zastępuje on autoryzacji endpointu.

R2 nie obsługuje formularzowego presigned POST. Jego presigned URLs obsługują m.in. PUT. Nie przenoś powyższego kodu do R2 bez zmian. Jeśli potrzebujesz twardego limitu wejściowego, wybierz mechanizm, który rzeczywiście go egzekwuje. Kontrola rozmiaru po uploadzie i usunięcie obiektu chronią dalsze przetwarzanie, lecz nie cofają kosztów przyjęcia danych.

Co sprawdzać w zawartości pliku

file.type, rozszerzenie i rozmiar przesłany w JSON są deklaracjami klienta. Biblioteka file-type rozpoznaje sygnatury formatów binarnych, ale wynik nie potwierdza poprawności dokumentu ani braku szkodliwej zawartości. CSV nie ma jednoznacznych magic bytes, więc wymaga parsowania tekstu i walidacji rekordów.

Dla obrazu sprawdź liczbę bajtów, dozwolony format i wymiary, a następnie dekoduj go z limitem pikseli oraz czasu pracy. Przy avatarach zapisuj ponownie zakodowany wynik, usuwając zbędne metadane. Dla PDF-ów i innych dokumentów dobierz skanowanie antywirusowe lub rekonstrukcję zawartości do ryzyka aplikacji. Wynik skanera jest jedną z warstw ochrony.

Parsery uruchamiaj z ograniczeniem pamięci, czasu i współbieżności, szczególnie przy archiwach i skompresowanych formatach. Sam timeout obietnicy JavaScript nie zatrzymuje pracy synchronicznego parsera; izolowany worker lub proces musi dać się zakończyć. Zasady te rozwija OWASP File Upload Cheat Sheet.

Generowanie PDF z danych w Next.js

@react-pdf/renderer obsługuje przeglądarkę i serwer. Dla prywatnego raportu z bazy wybierz Node API i runtime = 'nodejs'. Pokazany wariant korzysta z lokalnego fontu oraz renderToBuffer; to uzasadnia wybór Node, bez odwoływania się do rzekomej zależności od natywnego canvas. Różnice środowisk opisuję w poradniku o Node i Edge Runtime.

Code
yarn add @react-pdf/renderer

Zarejestruj font zawierający polskie znaki. Dokumentacja react-pdf wymienia TTF i WOFF; dla pogrubienia dostarcz właściwy wariant. Poniżej prosty raport zamiast rozbudowanego szablonu faktury.

Code
// lib/pdf/report.tsx
import 'server-only'
import path from 'node:path'
import { Document, Page, Text, Font } from '@react-pdf/renderer'
 
Font.register({
  family: 'Inter',
  src: path.join(process.cwd(), 'assets/fonts/Inter-Regular.ttf'),
})
 
export function ReportPDF({ title, rows }: { title: string; rows: string[] }) {
  return (
    <Document>
      <Page size="A4" style={{ padding: 32, fontFamily: 'Inter', fontSize: 11 }}>
        <Text style={{ fontSize: 18, marginBottom: 16 }}>{title}</Text>
        {rows.map((row, index) => <Text key={index}>{row}</Text>)}
      </Page>
    </Document>
  )
}

Dołącz plik fontu do artefaktu wdrożeniowego. Przy dynamicznych ścieżkach sprawdź wynik tracingu i w razie potrzeby skonfiguruj outputFileTracingIncludes w ustawieniach Next.js. Nie przyjmuj od użytkownika dowolnych adresów fontów ani obrazów do pobrania przez generator, ponieważ otwiera to drogę do SSRF.

Code
// app/api/reports/[id]/pdf/route.ts
import { createElement } from 'react'
import { renderToBuffer } from '@react-pdf/renderer'
import { ReportPDF } from '@/lib/pdf/report'
import { auth } from '@/auth'
import { db } from '@/lib/db'
 
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
  const report = await db.report.findFirst({
    where: { id, ownerId: session.user.id },
    select: { title: true, rows: { take: 201, select: { text: true } } },
  })
  if (!report) return new Response('Not found', { status: 404 })
  if (report.rows.length > 200 || report.title.length > 200 ||
      report.rows.some((row) => row.text.length > 500)) {
    return new Response('Report exceeds synchronous generation limits', { status: 422 })
  }
 
  const buffer = await renderToBuffer(createElement(ReportPDF, {
    title: report.title,
    rows: report.rows.map((row) => row.text),
  }))
 
  return new Response(new Uint8Array(buffer), {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': 'attachment; filename="raport.pdf"',
      'Cache-Control': 'private, no-store',
      'X-Content-Type-Options': 'nosniff',
    },
  })
}

Model report w przykładzie ma właściciela, tytuł i relację rows z polem text. Sprawdzenie ownerId chroni przed odczytem cudzego dokumentu po odgadnięciu ID. W aplikacji organizacyjnej zastosuj właściwe reguły dostępu zespołu.

Endpoint obejmij limitem żądań i równoległych renderów. renderToBuffer trzyma wynik w pamięci; większe raporty generuj w kolejce, zapisuj prywatnie i wydawaj po autoryzacji. Rejestruj awarie renderowania bez zapisywania treści dokumentu w logach. Cache gotowych plików musi uwzględniać właściciela i wersję danych.

Import CSV z podglądem przed zatwierdzeniem

Dla małych plików parsowanie w przeglądarce daje szybki podgląd. Poniższy komponent ogranicza plik do 50 KiB i 100 rekordów. Przycisk zatwierdzenia wywołuje onImport, które powinno zwrócić obietnicę odrzucaną przy błędzie serwera. Walidacja biznesowa odbywa się ponownie w Server Action.

Code
yarn add papaparse zod
yarn add -D @types/papaparse
Code
// components/csv-import.tsx
'use client'
 
import { useEffect, useId, useRef, useState } from 'react'
import Papa from 'papaparse'
 
type CSVRow = Record<string, string>
const COLUMNS = ['name', 'price', 'category', 'sku']
 
export function CSVImport({ onImport, encoding = 'UTF-8' }: {
  onImport: (rows: CSVRow[]) => Promise<void>
  encoding?: string
}) {
  const id = useId()
  const generation = useRef(0)
  const [rows, setRows] = useState<CSVRow[]>([])
  const [error, setError] = useState('')
  const [busy, setBusy] = useState(false)
 
  useEffect(() => {
    generation.current++
    setRows([])
    return () => { generation.current++ }
  }, [encoding])
 
  function read(file?: File) {
    const ticket = ++generation.current
    setRows([])
    setError('')
    if (!file) return
    if (file.size === 0 || file.size > 50 * 1024) {
      setError('Wybierz niepusty CSV do 50 KiB')
      return
    }
    Papa.parse<CSVRow>(file, {
      header: true,
      encoding,
      skipEmptyLines: 'greedy',
      complete(result) {
        if (ticket !== generation.current) return
        const fields = result.meta.fields ?? []
        const validColumns = fields.length === COLUMNS.length &&
          COLUMNS.every((column) => fields.includes(column))
        if (result.errors.length || !validColumns ||
            result.data.length === 0 || result.data.length > 100) {
          setError('CSV musi mieć kolumny name, price, category, sku i od 1 do 100 rekordów')
          return
        }
        setRows(result.data)
      },
      error() {
        if (ticket === generation.current) setError('Nie udało się odczytać pliku')
      },
    })
  }
 
  async function submit() {
    setBusy(true)
    setError('')
    try {
      await onImport(rows)
      setRows([])
    } catch {
      setError('Import nie został potwierdzony. Sprawdź wynik na serwerze przed ponowieniem.')
    } finally {
      setBusy(false)
    }
  }
 
  return (
    <section>
      <label htmlFor={id}>CSV produktów (UTF-8 lub wybrane kodowanie)</label>
      <input id={id} type="file" accept=".csv" disabled={busy}
        onChange={(event) => {
          read(event.target.files?.[0])
          event.target.value = ''
        }} />
      {error && <p role="alert">{error}</p>}
      {rows.length > 0 && <>
        <p>Podgląd pierwszych 5 z {rows.length} rekordów:</p>
        <table>
          <thead><tr>{COLUMNS.map((c) => <th key={c} scope="col">{c}</th>)}</tr></thead>
          <tbody>{rows.slice(0, 5).map((row, index) => (
            <tr key={index}>{COLUMNS.map((c) => <td key={c}>{row[c]}</td>)}</tr>
          ))}</tbody>
        </table>
        <button type="button" disabled={busy} onClick={submit}>
          {busy ? 'Importowanie…' : 'Zatwierdź import'}
        </button>
      </>}
    </section>
  )
}

Numer generation zapobiega nadpisaniu podglądu wynikiem starszego odczytu. Kolumny pochodzą z meta.fields; komponent odrzuca plik bez rekordów i nie uruchamia importu podczas samego parsowania.

Starsze eksporty CSV mogą używać kodowania systemowego, np. Windows-1250. Zależy to od wersji aplikacji i wybranego formatu zapisu. Zaproponuj eksport „CSV UTF-8” albo pozwól wskazać kodowanie przez prop encoding. Separator i kodowanie to odrębne ustawienia.

Walidacja i zapis w Server Action

Waliduj całe wejście przed pierwszym zapisem. Poniższy wariant przyjmuje ceny jako tekst z kropką i maksymalnie dwoma miejscami dziesiętnymi, a do bazy przekazuje całkowitą liczbę groszy. Odrzuca cały mały import, jeśli choć jeden rekord jest błędny.

Code
// lib/import-schema.ts
import { z } from 'zod'
 
const priceSchema = z.string().trim()
  .regex(/^(0|[1-9]\d{0,6})(\.\d{1,2})?$/)
  .transform((value) => {
    const [whole, fraction = ''] = value.split('.')
    return Number(whole) * 100 + Number(fraction.padEnd(2, '0'))
  })
  .refine((cents) => cents > 0, 'Cena musi być dodatnia')
 
export const importSchema = z.array(z.object({
  name: z.string().trim().min(1).max(200),
  price: priceSchema,
  category: z.string().trim().min(1).max(100),
  sku: z.string().trim().max(64),
}).strict()).min(1).max(100)
Code
// actions/import-products.ts
'use server'
 
import { auth } from '@/auth'
import { db } from '@/lib/db'
import { importSchema } from '@/lib/import-schema'
import { revalidatePath } from 'next/cache'
 
export async function importProducts(input: unknown) {
  const session = await auth()
  if (!session?.user?.id) return { ok: false, error: 'Brak autoryzacji' }
 
  const parsed = importSchema.safeParse(input)
  if (!parsed.success) {
    return { ok: false, error: 'Niepoprawne dane importu',
      issues: parsed.error.issues.slice(0, 20).map((issue) => ({
        path: issue.path.join('.'), message: issue.message,
      })) }
  }
 
  // W tym modelu zalogowany użytkownik tworzy własne produkty.
  // Role i członkostwo w organizacji sprawdź tutaj, jeśli występują.
  try {
    await db.$transaction(parsed.data.map((row) => db.product.create({
      data: {
        name: row.name,
        priceCents: row.price,
        category: row.category,
        sku: row.sku || null,
        ownerId: session.user.id,
      },
    })))
  } catch {
    return { ok: false, error: 'Nie zapisano importu. Sprawdź dane i ograniczenia bazy.' }
  }
 
  revalidatePath('/dashboard/products')
  return { ok: true, imported: parsed.data.length }
}

Przykład zakłada transakcje w bazie i pole priceCents typu integer. Adapter onImport musi sprawdzić result.ok i odrzucić obietnicę przy błędzie, aby interfejs zachował dane. Własność pochodzi z sesji; użytkownik nie przesyła ownerId.

Transakcja zapobiega częściowemu zapisowi tej paczki, ale nie zapewnia idempotencji ponowionego żądania. W produkcji dodaj identyfikator importu lub paczki, przypisany do użytkownika, i unikalność tej pary w bazie. W jednej transakcji zapisz produkty oraz wynik paczki. Ponowienie z tym samym ID i tą samą zawartością zwraca zapisany wynik; inne dane pod tym ID odrzucasz. To zabezpiecza również sytuację, w której zapis się udał, lecz odpowiedź nie dotarła.

Duże importy i limity platformy

Server Actions mają domyślny limit body 1 MB, konfigurowalny przez experimental.serverActions.bodySizeLimit. Rozmiar CSV nie jest rozmiarem zserializowanego żądania. Vercel Functions ograniczają payload żądania i odpowiedzi do 4,5 MB; podniesienie limitu Next.js nie usuwa limitów hostingu.

Dla dużych importów użyj step lub chunk w Papa Parse. Ogranicz wielkość rekordu, paczki w bajtach, liczbę zapamiętanych błędów i równoległych wysyłek. Gdy wysyłasz paczkę, wstrzymaj parser i wznów po odpowiedzi, jeśli wybrany tryb to obsługuje. Wariant z workerem ma inne ograniczenia sterowania; samo worker: true nie kontroluje wzrostu bufora.

Długie zadania przenieś do kolejki z postępem, anulowaniem i idempotentnym zapisem paczek. Autoryzację oraz walidację testuj tak samo jak inne mutacje, zgodnie z poradnikiem o testach Server Actions i Route Handlers.

Eksport danych do CSV z Next.js

Escapowanie separatorów i cudzysłowów utrzymuje strukturę CSV. Osobno trzeba ograniczyć interpretację danych jako formuł. Dotyczy to również nagłówków, jeśli powstają z danych użytkownika. Poniższy wariant eksportuje komórki jako tekst przeznaczony do otwierania w arkuszu i obejmuje ochroną znaki sterujące oraz pełnoszerokie odpowiedniki prefiksów formuł.

Code
// lib/export-csv.ts
const FORMULA_PREFIX = /^[\s\u0000-\u001f]*[=+\-@=+-@]/u
const LEADING_CONTROL = /^[\t\r\n]/
 
export function escapeCSVCell(value: unknown): string {
  const raw = String(value ?? '')
  const text = FORMULA_PREFIX.test(raw) || LEADING_CONTROL.test(raw)
    ? `'${raw}` : raw
  return `"${text.replace(/"/g, '""')}"`
}
 
export function buildCSV(data: Record<string, unknown>[]): string {
  if (data.length === 0) return ''
  const headers = Object.keys(data[0])
  return [
    headers.map(escapeCSVCell).join(','),
    ...data.map((row) => headers.map((key) => escapeCSVCell(row[key])).join(',')),
  ].join('\r\n')
}
 
// Wywołuj z event handlera w komponencie klientowym.
export function downloadCSV(data: Record<string, unknown>[]) {
  if (data.length === 0) return
  const blob = new Blob(['\uFEFF', buildCSV(data)], { type: 'text/csv;charset=utf-8' })
  const url = URL.createObjectURL(blob)
  const link = document.createElement('a')
  link.href = url
  link.download = 'eksport.csv'
  document.body.appendChild(link)
  link.click()
  link.remove()
  setTimeout(() => URL.revokeObjectURL(url), 1000)
}

Te ograniczenia opisuje OWASP CSV Injection. Dla integracji między systemami uzgodnij osobny format i zasady interpretacji wartości, zamiast bezwarunkowo zmieniać liczby ujemne w tekst.

BOM pomaga Excelowi rozpoznać UTF-8, lecz nie ustawia separatora. Przy ustawieniach regionalnych oczekujących średnika zaimportuj plik z jawnym wyborem przecinka albo przygotuj wariant eksportu z uzgodnionym separatorem. Jeśli potrzebujesz XLSX, instaluj SheetJS według instrukcji producenta; publiczny pakiet xlsx w npm jest nieaktualny.

Podgląd obrazów przed uploadem

Podgląd jest lokalny i nie potwierdza bezpieczeństwa obrazu. Krótki komponent dla pojedynczego avatara wystarczy, by pokazać zarządzanie pamięcią bez rozbudowanego drag-and-drop.

Code
// components/image-preview.tsx
'use client'
 
import { useEffect, useId, useState } from 'react'
import Image from 'next/image'
 
const ALLOWED = new Set(['image/jpeg', 'image/png', 'image/webp'])
 
export function ImagePreview() {
  const id = useId()
  const [file, setFile] = useState<File | null>(null)
  const [preview, setPreview] = useState('')
  const [error, setError] = useState('')
 
  useEffect(() => {
    if (!file) { setPreview(''); return }
    const url = URL.createObjectURL(file)
    setPreview(url)
    return () => URL.revokeObjectURL(url)
  }, [file])
 
  return (
    <section>
      <label htmlFor={id}>Avatar, JPG, PNG lub WebP do 5 MiB</label>
      <input id={id} type="file" accept="image/jpeg,image/png,image/webp"
        onChange={(event) => {
          const selected = event.target.files?.[0]
          setFile(null)
          setError('')
          if (selected) {
            if (!ALLOWED.has(selected.type) || selected.size === 0 ||
                selected.size > 5 * 1024 * 1024) {
              setError('Wybierz JPG, PNG lub WebP do 5 MiB')
            } else setFile(selected)
          }
          event.target.value = ''
        }} />
      {error && <p role="alert">{error}</p>}
      {file && preview && <>
        <Image src={preview} alt="Podgląd wybranego avatara" width={160} height={160}
          unoptimized />
        <button type="button" onClick={() => setFile(null)}>Usuń podgląd</button>
      </>}
    </section>
  )
}

Cleanup efektu zwalnia URL przy zmianie pliku i odmontowaniu komponentu. unoptimized jawnie zaznacza lokalny charakter podglądu; Next.js automatycznie wyłącza optymalizację dla blob:. Serwer nadal musi przeprowadzić kontrolę obrazu, w tym limit pikseli po dekompresji. Dalsze przetwarzanie opisuję w poradniku o optymalizacji obrazów.

Co sprawdzić przed wdrożeniem

PróbaOczekiwany wynik
Cudze ID raportu lub uploaduBrak dostępu do zawartości i podpisu pobierania
Plik większy niż limit, fałszywy MIME, uszkodzony obrazOdrzucenie na właściwej warstwie, bez udostępnienia
Powtórny zapis starym podpisem po weryfikacjiBrak możliwości zmiany opublikowanego wyniku
Powtórzony webhook albo awaria skaneraBrak podwójnej publikacji, plik pozostaje niedostępny przy błędzie
Pusty CSV, same nagłówki, zmiana pliku w trakcie odczytuCzytelny błąd lub aktualny podgląd, bez automatycznego importu
null, obiekt zamiast tablicy, zbyt długie pola w Server ActionOdrzucenie bez zapisu do bazy
Zerwane połączenie po zatwierdzonym zapisiePonowienie z tym samym ID nie duplikuje danych
Formuły, przecinki, cudzysłowy i nowe linie w nagłówkach oraz wartościachZachowana struktura i zweryfikowane zachowanie docelowego arkusza
Wiele równoległych uploadów lub renderów PDFEgzekwowane limity użytkownika, czasu i zasobów
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?

Użyj biblioteki obsługującej XLSX, np. SheetJS, i instaluj ją zgodnie z aktualną instrukcją producenta. Publiczny pakiet xlsx w npm jest nieaktualny. Wartości od użytkowników zapisuj jako komórki tekstowe, bez przypisywania im formuł. XLSX przydaje się przy wielu arkuszach i formatowaniu; CSV wystarcza do prostego eksportu tabeli.

Czy @react-pdf/renderer działa na Edge Runtime?

Pokazany wariant z renderToBuffer i lokalnymi plikami fontów wymaga Node.js. Ustaw runtime nodejs w Route Handlerze. Biblioteka obsługuje również generowanie PDF w przeglądarce, więc nie należy uzasadniać wyboru Node rzekomym wymaganiem natywnego canvas. Zgodność innych wariantów z Edge wymaga osobnej weryfikacji zależności i dostępnych API.

Jak obsłużyć bardzo duże pliki CSV (100 tys.+ wierszy)?

Parsuj je porcjami przez step lub chunk w Papa Parse. Ogranicz liczbę buforowanych wierszy i błędów, rozmiar pojedynczego rekordu oraz liczbę równoległych żądań. Wstrzymuj parser na czas wysyłania paczki, jeśli używany tryb to wspiera. Sam callback step nie gwarantuje stałego zużycia pamięci. Długie importy przenieś do zadania w tle z raportem postępu.

Dlaczego walidacja pliku w przeglądarce nie wystarcza?

Żądanie można wysłać z pominięciem interfejsu. Serwer musi sprawdzić uprawnienia, rzeczywisty rozmiar i zawartość. Magic bytes pomagają rozpoznać format binarny, ale nie potwierdzają bezpieczeństwa pliku. CSV wymaga parsowania i walidacji danych, ponieważ nie ma jednoznacznej sygnatury. Do czasu zakończenia kontroli przechowuj upload w prywatnej kwarantannie.

Po co dodawać BOM do eksportowanego pliku CSV?

BOM pomaga Excelowi rozpoznać UTF-8 i poprawnie wyświetlić polskie znaki. Nie wybiera separatora kolumn i nie zabezpiecza przed formułami. Przy eksporcie do innych systemów uzgodnij wymagania odbiorcy, ponieważ nie każdy importer usuwa BOM z pierwszego pola.

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.

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

Skontaktuj się ze mną
LH.pl – Hosting Mango

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
Backend dla frontendowca: cache, deployment i bezpieczeństwo

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

Next.js jako backend: architektura aplikacji full-stack

Next.js może obsługiwać interfejs, logikę biznesową i dostęp do bazy w jednym projekcie. Ten przewodnik pokazuje, jak połączyć komponenty serwerowe, Server Actions i Route Handlers z autoryzacją, cache i zadaniami w tle. Na przykładzie panelu zamówień przejdziemy od odczytu danych do zapisu i utrzymania na produkcji, wskazując momenty, w których warto wydzielić osobną usługę.

Maciej Sala

Maciej Sala

Founder StriveLab

Backend dla frontendowca: serwer, bazy danych i API

Frontend rzadko kończy się na komponencie i jednym fetch , a im bliżej realnego produktu, tym częściej jakość UI zależy od zachowania backendu. Jak API paginuje dane, jak zwraca błędy, jak kontroluje dostęp, co robi po przekroczeniu limitu czasu i czy potrafi bezpiecznie przyjąć ponowione żądanie.

Maciej Sala

Maciej Sala

Founder StriveLab

LH.pl – Cloud Server 1C4G