# Što ćete izgraditi#
Ovaj vodič prikazuje production-ready obrazac za izravno učitavanje iz preglednika na AWS S3 ili Cloudflare R2 pomoću presigned URL-ova u Next.js App Routeru. Implementirat ćete serversko potpisivanje, slojevitu validaciju, opcionalno skeniranje na malware te robusne ponovne pokušaje (retry) bez usmjeravanja bajtova datoteke kroz vaš Next.js server.
Ako trebate kratko objašnjenje zašto je to važno: uploadi preko servera često padaju pod opterećenjem zbog memorijskih ograničenja i timeouta, dok izravni upload u object storage prebacuje “teški dio” na infrastrukturu dizajniranu baš za to.
Ciljana ključna riječ: Next.js file upload presigned URL S3 Cloudflare R2
# Pregled arhitekture: izravni upload, pa provjera#
Siguran upload flow ima tri faze:
- 1Zahtjev: preglednik traži od vašeg servera dozvolu za upload.
- 2Upload: preglednik učitava izravno na S3 ili R2 koristeći presigned URL.
- 3Finalizacija: preglednik javlja serveru da je upload gotov, a server to verificira i evidentira.
Time uklanjate velike request bodyje iz Next.js runtimea, što je ključno na serverless platformama gdje su request bodyji često ograničeni, a dugi uploadi udaraju u timeoute.
Flow izravnog uploada (visoka razina)#
| Korak | Tko | Što se događa | Zašto je bitno |
|---|---|---|---|
| 1 | Klijent | Šalje filename, size, type vašem API-ju | Minimalan payload, brz zahtjev |
| 2 | Server | Auth provjere, validira metapodatke, generira object key, vraća presigned URL | Centralna sigurnosna kontrolna točka |
| 3 | Klijent | PUT-a datoteku na S3 ili R2 | Koristi propusnost storage mreže |
| 4 | Klijent | Zove finalize endpoint s key i ETag | Izbjegava vjerovanje klijentovim tvrdnjama |
| 5 | Server | HEAD-a objekt, validira size i content-type, označava status | Sprječava spoofing i djelomične uploadove |
| 6 | Opcionalno | Scanner | Skenira objekt, pa premješta u safe bucket ili označava clean |
🎯 Ključna poruka: Aplikacija bi trebala tretirati datoteku kao nepouzdanu dok se ne verificira na serveru i, za rizične use-caseove, ne skenira.
# Preduvjeti#
| Zahtjev | Verzija | Napomene |
|---|---|---|
| Next.js | 14 ili 15 | App Router route handlers korišteni u primjerima |
| Node.js runtime za potpisivanje | 18+ | Presigning koristi crypto i AWS SDK |
| AWS S3 ili Cloudflare R2 | — | R2 koristi S3-kompatibilan endpoint |
| Auth sustav | Bilo koji | Koristite session cookies ili JWT; vežite upload uz korisnika |
Za obrasce autentikacije pogledajte Next.js opcije autentikacije. Upload endpointi moraju biti zaštićeni, inače presigned URL-ovi postaju javni “relay” za upload.
# Postavke storagea: osnove S3 vs R2#
S3 i R2 podržavaju isti S3-kompatibilni API, ali operativni detalji se razlikuju.
Preporučeni layout bucketa#
Koristite barem dva logička područja:
- staging: gdje završavaju izravni uploadi
- public ili processed: gdje premještate datoteke nakon verifikacije i skeniranja
To smanjuje rizik posluživanja nesigurnog sadržaja i olakšava lifecycle politike.
Brza usporedba za ovaj use case#
| Mogućnost | AWS S3 | Cloudflare R2 |
|---|---|---|
| Presigned PUT URL-ovi | Da | Da |
| Multi-part upload | Da | Da |
| Event triggeri | Jake native opcije | Radi, ali ovisi o vašem stacku |
| Egress naknade | Tipičan cloud pricing | Često niže, posebno preko Cloudflare-a |
| S3 API kompatibilnost | Native | S3-kompatibilan endpoint |
# Korak 1: serversko potpisivanje u App Routeru#
Napravit ćete route handler koji validira namjeru uploada i vraća presigned PUT URL plus object key.
Environment varijable#
Credentialse držite isključivo na serveru.
| Varijabla | Primjer | Napomene |
|---|---|---|
S3_ACCESS_KEY_ID | AKIA... | Koristite least-privilege IAM |
S3_SECRET_ACCESS_KEY | ... | Nikad ne izlažite klijentu |
S3_BUCKET | myapp-uploads | Odvojeni bucketi po env-u |
S3_REGION | eu-central-1 | Za R2 često auto |
S3_ENDPOINT | https://<account>.r2.cloudflarestorage.com | Potrebno za R2 |
Postavljanje S3 ili R2 klijenta#
Napravite mali helper. Ovo ostaje na serveru.
// lib/s3.ts
import { S3Client } from "@aws-sdk/client-s3";
export function getS3Client() {
return new S3Client({
region: process.env.S3_REGION!,
endpoint: process.env.S3_ENDPOINT || undefined,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
forcePathStyle: !!process.env.S3_ENDPOINT,
});
}forcePathStyle je često potreban za S3-kompatibilne providere i lokalno testiranje.
Route handler: kreiranje presigned URL-a#
Ovaj endpoint treba:
- auth provjeru
- validirati size i type prema vašoj politici
- generirati siguran object key vezan uz korisnika
- kreirati kratkotrajni presigned URL
// app/api/uploads/sign/route.ts
import { NextResponse } from "next/server";
import { randomUUID } from "crypto";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { getS3Client } from "@/lib/s3";
const MAX_BYTES = 10 * 1024 * 1024;
const ALLOWED_TYPES = ["image/jpeg", "image/png", "application/pdf"];
export async function POST(req: Request) {
const sessionUserId = req.headers.get("x-user-id"); // replace with real auth
if (!sessionUserId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const fileName = String(body.fileName || "");
const fileType = String(body.fileType || "");
const fileSize = Number(body.fileSize || 0);
if (!ALLOWED_TYPES.includes(fileType)) {
return NextResponse.json({ error: "Unsupported file type" }, { status: 400 });
}
if (!Number.isFinite(fileSize) || fileSize <= 0 || fileSize > MAX_BYTES) {
return NextResponse.json({ error: "File too large" }, { status: 400 });
}
const safeExt = fileType === "application/pdf" ? "pdf" : fileType.split("/")[1];
const objectKey = `staging/${sessionUserId}/${randomUUID()}.${safeExt}`;
const s3 = getS3Client();
const bucket = process.env.S3_BUCKET!;
const cmd = new PutObjectCommand({
Bucket: bucket,
Key: objectKey,
ContentType: fileType,
// Optionally: metadata to help downstream processing
Metadata: {
"original-name": fileName.slice(0, 120),
"uploader-id": sessionUserId,
},
});
const uploadUrl = await getSignedUrl(s3, cmd, { expiresIn: 60 });
return NextResponse.json({
uploadUrl,
key: objectKey,
maxBytes: MAX_BYTES,
requiredContentType: fileType,
});
}Zašto je kratko trajanje bitno: ako presigned URL procuri, napadač ima mali prozor. Česta production vrijednost je 30 do 120 sekundi.
⚠️ Upozorenje: Ne gradite object key iz filenamea koji šalje korisnik. Path trikovi i kolizije su realni. Uvijek generirajte vlastiti key, a originalno ime spremite kao metadata.
# Korak 2: upload u pregledniku s retryjima i progresom#
Klijent uploada izravno na presigned URL koristeći fetch s PUT. Dodajte retryje jer mobilne mreže pucaju, korporativni proxyji znaju resetirati konekcije, a korisnici zatvaraju laptop usred uploada.
Minimalna funkcija za upload na klijentu#
// lib/uploadDirect.ts
export async function uploadViaPresignedUrl(params: {
file: File;
uploadUrl: string;
contentType: string;
retries?: number;
}) {
const { file, uploadUrl, contentType } = params;
const retries = params.retries ?? 2;
let lastError: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const res = await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": contentType },
body: file,
});
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
return { etag: res.headers.get("etag") };
} catch (err) {
lastError = err;
await new Promise((r) => setTimeout(r, 400 * (attempt + 1)));
}
}
throw lastError;
}Ako trebate progress, fetch još uvijek nema stabilne evente za upload progress u mnogim preglednicima. Za velike datoteke koristite XMLHttpRequest za progress ili implementirajte multipart upload s client-side bibliotekom.
Orkestracija na klijentu: sign, upload, finalize#
Zadržite finalize korak čak i ako već imate key. To je prilika serveru da verificira i veže upload uz vašu bazu.
// example usage in a client component action
export async function uploadFile(file: File) {
const signRes = await fetch("/api/uploads/sign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileName: file.name, fileType: file.type, fileSize: file.size }),
});
if (!signRes.ok) throw new Error("Sign failed");
const { uploadUrl, key, requiredContentType } = await signRes.json();
const { etag } = await uploadViaPresignedUrl({
file,
uploadUrl,
contentType: requiredContentType,
retries: 2,
});
const finRes = await fetch("/api/uploads/finalize", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, etag }),
});
if (!finRes.ok) throw new Error("Finalize failed");
return finRes.json();
}# Korak 3: finalize endpoint sa serverskom verifikacijom#
Finalizacija mora potvrditi da objekt postoji i da odgovara vašim očekivanjima.
Minimalno:
- osigurati da key pripada korisniku koji traži
- HEAD-ati objekt i validirati:
ContentLengthmanji ili jednak vašem maxContentTypeodgovara allowlisti
- spremiti DB zapis sa statusom
uploadedilipending_scan
Route handler: finalize#
// app/api/uploads/finalize/route.ts
import { NextResponse } from "next/server";
import { HeadObjectCommand } from "@aws-sdk/client-s3";
import { getS3Client } from "@/lib/s3";
const MAX_BYTES = 10 * 1024 * 1024;
const ALLOWED_TYPES = ["image/jpeg", "image/png", "application/pdf"];
export async function POST(req: Request) {
const sessionUserId = req.headers.get("x-user-id"); // replace with real auth
if (!sessionUserId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { key } = await req.json();
const objectKey = String(key || "");
if (!objectKey.startsWith(`staging/${sessionUserId}/`)) {
return NextResponse.json({ error: "Invalid key" }, { status: 403 });
}
const s3 = getS3Client();
const bucket = process.env.S3_BUCKET!;
const head = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: objectKey }));
const size = Number(head.ContentLength || 0);
const type = String(head.ContentType || "");
if (size <= 0 || size > MAX_BYTES) {
return NextResponse.json({ error: "Invalid size" }, { status: 400 });
}
if (!ALLOWED_TYPES.includes(type)) {
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
}
// Persist to DB here: userId, key, size, type, status
// For risky content: set status = "pending_scan"
return NextResponse.json({
ok: true,
key: objectKey,
size,
type,
status: "uploaded",
});
}Zašto je to bitno: metapodatke koje šalje klijent je lako lažirati. Verifikacija preko HEAD-a je jeftina i zaustavlja velik broj zloupotreba.
💡 Savjet: Ako imate CDN ispred, nikad ne servirajte direktno iz
staging. Servirajte samo iz zasebnogpublicprefixa ili bucketa nakon verifikacije i skeniranja.
# Strategija validacije: veličina, tip i sadržaj#
Validacija treba slojeve. Svaki sloj hvata različite probleme.
Validacija na klijentu (UX)#
Provjere na klijentu smanjuju uzaludno vrijeme, ali nisu sigurnost.
- blokirajte očito pogrešne tipove datoteka
- prikažite max veličinu prije početka uploada
- prikažite progress i očekivano preostalo vrijeme
Validacija na serveru (sigurnosna kontrolna točka)#
Server mora provoditi politiku u oba endpointa:
- u
/sign: validirati traženifileTypeifileSize - u
/finalize: validirati stvarne metapodatke pohranjenog objekta
Ne vjerujte samo MIME tipu#
Preglednici mogu slati application/octet-stream ili pogrešne tipove, a napadači mogu lažirati image/png.
Ako je rizik visok, dodajte barem jedno od:
- content sniffing nakon uploada
- detekciju magic-bytes u workeru
- antivirusno skeniranje prije nego što datoteka postane dostupna
Za širi set sigurnosnih kontrola koristite ovaj checklist: Sigurnosni checklist za web aplikacije.
# Dodatno učvršćivanje sigurnosti: keyjevi, dozvole i zaštita od zloupotrebe#
Endpointi za izravni upload su česta meta za zloupotrebu bandwidtha i pohranu ilegalnog sadržaja. Ove kontrole koristite po defaultu.
IAM ili R2 API token: least privilege#
Credentialsi vašeg servera trebaju moći:
- PutObject u
staging/ - HeadObject u
staging/ - opcionalno CopyObject u
public/nakon skeniranja - opcionalno DeleteObject za čišćenje
Izbjegavajte wildcard pristup bucketu.
Object keyjevi: predvidljivost i kontrola pristupa#
Koristite nepogodive (unguessable) keyjeve i vežite ih uz korisnički namespace.
Dobar obrazac:
staging/userId/uuid.ext
Izbjegavajte:
uploads/myphoto.pnguploads/2026/05/myphoto.png
Kratki expiration i one-time intent#
Expiry presigned URL-a treba biti kratak, ali trebate i intent na razini aplikacije:
- kreirajte
upload_intentzapis suserId,key,maxBytes,type,expiresAt - dopustite finalize samo ako postoji odgovarajući intent
- expirajte i garbage-collectajte intentove
To blokira zloupotrebu “potpiši jednom, uploadaj zauvijek” kad URL procuri.
CORS#
Za izravne browser PUT-ove konfigurirajte bucket CORS da dopušta origin vaše stranice, PUT i potrebne headere.
Česti problemi:
- nedostaje
ETagu exposed headers - preširok
AllowedOrigins wildcardom kod autentificiranih aplikacija
Rate limiting#
Ograničite /sign po korisniku i IP-u. Čak i uz kratki expiry, bot može generirati tisuće signed URL-ova u minuti.
# Antivirus i opcije skeniranja malwarea#
Ako korisnici mogu uploadati PDF-ove, Office datoteke, arhive ili bilo što što se može dalje redistribuirati, skeniranje je obično nužno.
Praktičan pristup je tretirati upload kao karantenu dok se ne skenira.
Matrica opcija#
| Opcija | Gdje se izvodi | Prednosti | Nedostaci |
|---|---|---|---|
| ClamAV u container workeru | Vaša infrastruktura | Niska cijena, zrelo rješenje | Treba ops i ažuriranja |
| Managed skeniranje malwarea | Vendor | Manje ops posla | Dodatni trošak, vendor lock-in |
| Custom rule-based skeniranje | Worker | Pravila po mjeri | Nije zamjena za AV |
Tipičan scanning pipeline#
- 1Upload ide u
staging/sa statusompending_scan. - 2Background job preuzima objekt u worker i skenira.
- 3Ako je clean, kopira u
public/i označiclean. - 4Ako je zaraženo, obriše i označi
rejected.
Skeniranje držite izvan request patha. Čak i male datoteke mogu uzrokovati vremena skeniranja koja prelaze serverless limite.
ℹ️ Napomena: Ako morate procesirati slike, radite to u workeru i ponovno ih enkodirajte. Ponovno enkodiranje uklanja mnoge tehnike zlonamjernih payloadova u metapodacima i smanjuje veličinu za CDN isporuku.
# Rukovanje retryjima, idempotencijom i djelomičnim uploadovima#
Retryji nisu opcionalni u stvarnim uvjetima. Trik je učiniti retryje sigurnima.
Retry pravila koja rade#
| Tip greške | Što napraviti | Zašto |
|---|---|---|
| Network error tijekom PUT-a | Retry isti presigned URL ako je još važeći | Najčešća prolazna greška |
| 403 iz storagea | Zatražiti novi presigned URL | URL istekao ili signature mismatch |
| Upload je uspio, ali finalize je pao | Retry finalize s istim keyjem | Učinite finalize idempotentnim |
| Korisnik reload-a stranicu usred uploada | Nastaviti koristeći spremljeni key i intent | Izbjegava orphaned objekte |
Učinite finalize idempotentnim#
Finalize mora biti siguran za višestruke pozive. U DB terminima:
- spremite unique constraint na
key - ako zapis postoji i pripada korisniku, vratite success
To sprječava duple zapise kad klijent retrya.
Cleanup job za orphaned uploadove#
Očekujte orphaned objekte zbog napuštenih uploadova. Dnevni cleanup job treba brisati:
- objekte u
staging/starije od 24 do 72 sata - intentove kojima je istekao rok
To smanjuje trošak storagea i ograničava izloženost.
# Česte zamke u Next.js implementacijama uploada#
Ovi problemi uzrokuju većinu production incidenata s uploadima.
Zamka 1: upload kroz route handlere#
Uploadanje bajtova datoteke kroz Next.js endpoint je primamljivo, ali krhko:
- serverless platforme često imaju limite veličine bodyja
- veliki zahtjevi mogu premašiti execution timeoute
- skokovi Node memorije mogu srušiti procese kad se više uploadova događa paralelno
Direct-to-storage gotovo u potpunosti uklanja ove failure modeove.
Zamka 2: dugotrajni presigned URL-ovi#
Duga expiracija pretvara jednokratnu autorizaciju u dugotrajnu “capability”. Ako URL procuri kroz logove, browser ekstenzije ili podijeljene screenshotove, postaje vektor zloupotrebe.
Koristite kratki TTL i intent na razini aplikacije.
Zamka 3: posluživanje neskeniranog sadržaja#
Ako servirate direktno iz staging/, praktički objavljujete nepouzdan korisnički sadržaj. To može dovesti do distribucije malwarea, phishinga i štete za brend.
Karantena i skeniranje, pa promocija u public.
Zamka 4: nedostatak observabilityja#
Uploadovi padaju na načine koje korisnici ne mogu precizno opisati. Instrumentirajte flow:
- logirajte sign i finalize odgovore s correlation ID-jevima
- pratite rate neuspjelih uploadova po pregledniku i tipu mreže
- alertirajte na skokove u broju signed URL-ova po korisniku
Koristite strukturirani pristup iz Observability za web aplikacije: logovi, metrike, tracing.
# Production checklist: što isporučiti#
Koristite ovaj checklist prije puštanja u produkciju.
| Područje | Must-have | Preporučeno |
|---|---|---|
| Auth | Zaštićeni sign i finalize | Per-user kvote |
| Validacija | Veličina i allowlist tipova | Post-upload sniffing |
| Sigurnost | Random keyjevi, kratki TTL | Staging i promotion bucketi |
| Pouzdanost | Retryji na klijentu | Multipart za velike datoteke |
| Ops | Cleanup za orphans | Alerti na abuse patternse |
| Compliance | Audit logovi | Malware skeniranje za rizičan sadržaj |
# Ključne poruke#
- Koristite trokorak: potpis na serveru, izravan upload na S3 ili R2 iz preglednika, zatim finalize sa serverskom verifikacijom.
- Validaciju provodite dvaput: pri potpisivanju na temelju “claimed” metapodataka i pri finalizaciji pomoću HEAD-a za provjeru stvarne veličine i tipa objekta.
- Tretirajte
stagingkao nepouzdan: stavite u karantenu, opcionalno skenirajte na malware, zatim promovirajte na sigurnu lokaciju prije posluživanja. - Presigned URL-ove držite kratkotrajnima i vežite ih uz upload intent kako biste smanjili zloupotrebu ako URL procuri.
- Gradite za greške: dodajte retryje za PUT i napravite finalize idempotentnim, plus pokrenite cleanup job za napuštene uploadove.
# Zaključak#
Izravni upload u object storage je najpouzdaniji način za rad s datotekama u Next.js-u jer izbjegava pritisak na server memoriju i serverless timeoute, uz bolju skalabilnost. Implementirajte obrazac sign-upload-finalize, agresivno validirajte, stavite u karantenu i skenirajte kad treba te učinite retryje sigurnima pomoću idempotentne finalize logike.
Ako želite da Samioda implementira sigurni upload pipeline za S3 ili Cloudflare R2, uključujući skeniranje, observability i hardening usklađen s vašom auth postavkom, kontaktirajte nas i pomoći ćemo vam da to isporučite brzo i sigurno.
FAQ
Više iz kategorije Web razvoj
Sve →Next.js pozadinski poslovi u 2026.: redovi, Cron i dugotrajni zadaci na Vercelu (i šire)
Praktičan vodič za izvođenje pozadinskog rada u Next.js-u u 2026.: Vercel Cron, ograničenja serverlessa, redovi s Upstashom i Redisom te worker servisi za dugotrajne zadatke. Uključuje kriterije odlučivanja, arhitekturne dijagrame i checklistu za produkciju.
Next.js i18n s App Routerom: lokalizirano rutiranje, SEO i workflowi za sadržaj (vodič za 2026.)
Implementirajte Next.js i18n u App Routeru s lokaliziranim rutiranjem, detekcijom jezika, SEO-sigurnim metapodacima i skalabilnim workflowima prevođenja za JSON, CMS ili lokalizacijske platforme.
React obrasci u velikim aplikacijama: React Hook Form + Zod obrasci za složene proizvode
Najbolje prakse za React obrasce u velikim aplikacijama uz React Hook Form i Zod: validacija po shemi, višekratno upotrebljiva polja, asinkrone provjere, višekoračni tokovi, performanse, pristupačnost i obrasci integracije sa serverom/API-jem.
Trebate pomoć s projektom?
Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.
Povezani članci
Next.js multitenant SaaS arhitektura: modeli tenancije, rutiranje, autentikacija i izolacija podataka (Vodič za 2026.)
Praktičan vodič za Next.js multitenant SaaS arhitekturu: modeli tenancije, tenant-aware rutiranje uz App Router i middleware, obrasci autentikacije te učvršćivanje izolacije podataka kako bi se spriječila curenja.
Next.js autentikacija u 2026.: NextAuth vs Clerk vs Supabase (što koristimo na klijentskim projektima)
Praktična usporedba opcija za Next.js autentikaciju u 2026. — NextAuth, Clerk i Supabase — kroz UX, sigurnost, trošak, vrijeme postavljanja i enterprise zahtjeve, uz matrice odluke za SaaS, interne alate i B2B portale.
Next.js i18n s App Routerom: lokalizirano rutiranje, SEO i workflowi za sadržaj (vodič za 2026.)
Implementirajte Next.js i18n u App Routeru s lokaliziranim rutiranjem, detekcijom jezika, SEO-sigurnim metapodacima i skalabilnim workflowima prevođenja za JSON, CMS ili lokalizacijske platforme.