Build a file-sharing app with Next.js, Prisma and APULODI
Every app eventually needs to answer the same question: where do the files live? Not the code — the files. The invoice PDFs, the profile pictures, the exports, the things users attach at 11pm and expect to find tomorrow. Storing them in your database bloats it. Storing them on your server's disk breaks the moment you deploy to more than one machine. Storing them in S3 "directly" means you inherit multipart math, presigning, CORS, versioning and a subtle security surface.
This tutorial builds a small but complete file-sharing app — upload a
file, get a short link like /s/9tXk2mQa, anyone with the link downloads it —
and it makes the architectural split explicit:
- APULODI holds the bytes. Uploads go straight to storage, downloads are short-lived signed URLs, thumbnails are generated by the platform.
- Prisma + SQLite holds your domain model: which share points at which
APULODI file, how many times it was downloaded, which events you already
processed. Zero-configuration local database, one
dev.dbfile. - Next.js is the glue: route handlers for uploads and webhooks, server components for pages.
Every code block below is complete and copy-paste ready. By the end you will have a working product — not a sketch.
What you need
- Node.js 18.17 or newer (the SDK uses the global
fetch) - An APULODI account — sign up at apulodi.com, the free plan needs no card
- About 20 minutes
Step 1 — Create the Next.js app
Start from a clean App Router project with TypeScript, Tailwind and ESLint already wired up:
npx create-next-app@latest fileshare \
--typescript \
--tailwind \
--eslint \
--app \
--no-src-dir \
--import-alias "@/*"
cd fileshare
npm run dev
Open http://localhost:3000 and you should see the starter page. That is the
whole framework setup — no config files to hand-edit.
Install the two runtime dependencies this tutorial needs:
npm install @apulodi/sdk @prisma/client
npm install prisma --save-dev
Step 2 — Set up Prisma with SQLite
SQLite is the right database for a tutorial and for a surprising number of real products: it is a single file, there is no server to run, and Prisma treats it exactly like Postgres at the query level.
Initialize Prisma against SQLite:
npx prisma init --datasource-provider sqlite
This creates two things: a prisma/schema.prisma file and a .env with a
DATABASE_URL. Now model the app. A Share owns one APULODI file, has a
random slug for its public URL, and counts downloads. A ProcessedEvent
records webhook event IDs so we can deduplicate deliveries later (APULODI
retries until you answer 200, so handlers must be idempotent):
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model Share {
id String @id @default(cuid())
slug String @unique
apulodiFileId String @unique
filename String
contentType String
size Int
downloads Int @default(0)
createdAt DateTime @default(now())
@@index([createdAt])
}
model ProcessedEvent {
id String @id // the APULODI event id, e.g. "evt_..."
createdAt DateTime @default(now())
}
Create the database and generate the typed client:
npx prisma migrate dev --name init
If you open your project now you will find a prisma/dev.db file. That is
your entire database.
Prisma has one sharp edge in Next.js: hot reloading creates a new
PrismaClient for every module evaluation until you run out of database
handles. The standard fix is a singleton on the global object:
// lib/db.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
Step 3 — Connect APULODI
In the dashboard, create an organization, a
project, and an API key (Project → API Keys). The raw key is shown
exactly once — put it in .env.local (git-ignored by create-next-app):
# .env.local
APULODI_API_KEY=apk_live_your_key_here
Then create a single shared client. One module, one instance, imported by every route handler and server component:
// lib/apulodi.ts
import { Apulodi } from "@apulodi/sdk";
export const apulodi = new Apulodi({
apiKey: process.env.APULODI_API_KEY!,
});
Never import this module from a client component. The SDK is server-side only — bundling it into browser JavaScript would publish your API key. Every snippet below lives in a route handler or a server component, which is exactly where it belongs.
Step 4 — The upload route handler
The upload endpoint receives a standard multipart form, hands the bytes to
the SDK, and records the share in SQLite. One call to apulodi.files.upload()
performs the full three-step flow on your behalf: it creates the file record,
PUTs the bytes directly to storage, and finalizes the upload. The bytes
touch your Next.js server only in passing — they are never written to disk
there, never proxied, never stored:
// app/api/files/route.ts
import { randomBytes } from "node:crypto";
import { ApulodiError } from "@apulodi/sdk";
import { NextResponse } from "next/server";
import { apulodi } from "@/lib/apulodi";
import { prisma } from "@/lib/db";
const MAX_SIZE = 50 * 1024 * 1024; // 50 MB
export async function POST(request: Request) {
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file provided." }, { status: 400 });
}
if (file.size === 0 || file.size > MAX_SIZE) {
return NextResponse.json(
{ error: "File must be between 1 byte and 50 MB." },
{ status: 400 },
);
}
const bytes = Buffer.from(await file.arrayBuffer());
try {
// Direct to storage — the SDK handles presigning, the PUT and complete.
const uploaded = await apulodi.files.upload({
file: bytes,
fileName: file.name,
contentType: file.type || "application/octet-stream",
path: "shares", // logical folder, created automatically
metadata: { source: "fileshare-tutorial" },
});
const share = await prisma.share.create({
data: {
slug: randomBytes(8).toString("base64url"),
apulodiFileId: uploaded.id,
filename: uploaded.filename,
contentType: uploaded.contentType,
size: uploaded.size,
},
});
return NextResponse.json({ url: `/s/${share.slug}` }, { status: 201 });
} catch (error) {
if (error instanceof ApulodiError) {
// Typed errors: error.status, error.code, error.isClientError
return NextResponse.json({ error: error.message }, { status: 502 });
}
return NextResponse.json({ error: "Upload failed." }, { status: 500 });
}
}
Two details worth noticing:
path: "shares"— APULODI folders are logical paths, created on first use. You get the equivalent of an S3 prefix with none of the setup, and you can filter listings by it later.metadata— attach anything JSON-serializable. APULODI stores it alongside the file and returns it on every read, which is often enough to skip a database round-trip.
Files larger than 8 MiB are automatically uploaded with the multipart flow (chunked parts, abort-on-failure cleanup). You do not write any of that code.
Step 5 — The upload form
Replace the starter page with a real upload form. It is a client component because it tracks upload state; everything it calls is server-side:
// app/page.tsx
"use client";
import Link from "next/link";
import { useState } from "react";
export default function Home() {
const [uploading, setUploading] = useState(false);
const [shareUrl, setShareUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = new FormData(event.currentTarget);
setUploading(true);
setError(null);
setShareUrl(null);
const response = await fetch("/api/files", { method: "POST", body: form });
const data = (await response.json()) as { url?: string; error?: string };
if (!response.ok) {
setError(data.error ?? "Upload failed.");
} else if (data.url) {
setShareUrl(data.url);
event.currentTarget.reset();
}
setUploading(false);
}
return (
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center gap-6 p-6">
<h1 className="text-2xl font-bold">Share a file</h1>
<form onSubmit={onSubmit} className="flex flex-col gap-3">
<input
type="file"
name="file"
required
className="file:mr-3 file:rounded-md file:border-0 file:bg-black file:px-3 file:py-2 file:text-white"
/>
<button
type="submit"
disabled={uploading}
className="rounded-md bg-black px-4 py-2 font-medium text-white disabled:opacity-50"
>
{uploading ? "Uploading…" : "Upload"}
</button>
</form>
{error && <p className="text-sm text-red-600">{error}</p>}
{shareUrl && (
<div className="rounded-lg border p-4">
<p className="text-sm text-gray-600">Share this link:</p>
<Link href={shareUrl} className="font-mono text-sm underline">
{shareUrl}
</Link>
</div>
)}
</main>
);
}
Upload a file and you should get a link back. It leads nowhere yet — that is the next step.
Step 6 — The share page
The public face of the app: /s/<slug> shows what is being shared and offers
a download button. It is a server component — no client JavaScript ships
for this page at all.
The download button links to a tiny route handler that mints a short-lived presigned URL and redirects. This is the security model in one move: the share page is public, but the bytes are only reachable through URLs that expire. Leaked links stop working on their own.
While we are here, we get thumbnails for free. apulodi.files.transform() is
idempotent — the same parameters always return the same variant — so the page
can simply request a 640px WebP on every render. The first visitor triggers
processing and sees the plain file card; everyone after that gets the image:
// app/s/[slug]/page.tsx
import { notFound } from "next/navigation";
import { apulodi } from "@/lib/apulodi";
import { prisma } from "@/lib/db";
function formatBytes(bytes: number): string {
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`;
}
export default async function SharePage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const share = await prisma.share.findUnique({ where: { slug } });
if (!share) notFound();
// Idempotent: identical params return the same variant every time.
let thumbnail: string | null = null;
if (share.contentType.startsWith("image/")) {
const variant = await apulodi.files.transform(share.apulodiFileId, {
width: 640,
format: "webp",
});
if (variant.status === "ready") {
const { url } = await apulodi.files.variantDownloadUrl(
share.apulodiFileId,
variant.id,
{ expiresInSeconds: 3600 },
);
thumbnail = url;
}
}
return (
<main className="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center gap-4 p-6">
{thumbnail ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={thumbnail} alt={share.filename} className="max-h-72 rounded-lg" />
) : (
<div className="flex h-40 w-40 items-center justify-center rounded-lg bg-gray-100 text-4xl">
📄
</div>
)}
<h1 className="text-center text-xl font-semibold break-all">{share.filename}</h1>
<p className="text-sm text-gray-500">
{formatBytes(share.size)} · {share.downloads} downloads
</p>
<a
href={`/api/s/${share.slug}/download`}
className="rounded-md bg-black px-4 py-2 font-medium text-white"
>
Download
</a>
</main>
);
}
And the download route:
// app/api/s/[slug]/download/route.ts
import { NextResponse } from "next/server";
import { apulodi } from "@/lib/apulodi";
import { prisma } from "@/lib/db";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ slug: string }> },
) {
const { slug } = await params;
const share = await prisma.share.findUnique({ where: { slug } });
if (!share) {
return NextResponse.json({ error: "Not found." }, { status: 404 });
}
// 5-minute signed URL, served directly from storage — APULODI never
// proxies the bytes and neither does your app.
const { url } = await apulodi.files.downloadUrl(share.apulodiFileId, {
expiresInSeconds: 300,
});
await prisma.share.update({
where: { slug },
data: { downloads: { increment: 1 } },
});
return NextResponse.redirect(url);
}
Run npm run dev, upload a file, open the link. You now have a working
file-sharing app: direct-to-storage uploads, expiring downloads, live
download counts, and automatic WebP thumbnails — in about two hundred lines.
Step 7 — Stay in sync with webhooks
Right now your database and APULODI only agree when your code changes something. If a file is deleted through the dashboard — or by anyone else — your share page will happily keep pointing at a ghost. Webhooks close that gap: APULODI POSTs a signed event to your app within seconds of every state change.
Create an endpoint for the project in the dashboard (Project → Webhooks).
The signing secret is shown exactly once — it goes in .env.local:
# .env.local
APULODI_WEBHOOK_SECRET=whsec_your_secret_here
The receiver below follows the pattern that survives production: verify the
signature over the raw body, reject stale events, deduplicate on the
event ID (that is what ProcessedEvent is for), then dispatch:
// app/api/webhooks/apulodi/route.ts
import { verifyWebhookSignature } from "@apulodi/sdk";
import { prisma } from "@/lib/db";
import type { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
// 1. Verify first — over the RAW body, before any parsing. The signature
// covers exact bytes, so `request.text()`, not `request.json()`.
const raw = await request.text();
const signature = request.headers.get("apulodi-signature") ?? "";
if (!verifyWebhookSignature(process.env.APULODI_WEBHOOK_SECRET!, raw, signature)) {
return Response.json({ error: "invalid signature" }, { status: 401 });
}
const event = JSON.parse(raw) as {
id: string;
type: string;
createdAt: string;
data: { file: { id: string; path: string } };
};
// 2. Reject replays older than 5 minutes.
const age = Date.now() - new Date(event.createdAt).getTime();
if (age > 5 * 60_000) {
return Response.json({ error: "stale event" }, { status: 400 });
}
// 3. Deduplicate — delivery is at-least-once, so this may be a repeat.
const inserted = await prisma.processedEvent
.create({ data: { id: event.id } })
.catch(() => null); // unique constraint → already seen
if (!inserted) {
return Response.json({ ok: true });
}
// 4. Dispatch.
switch (event.type) {
case "file.deleted":
// The file is gone on APULODI's side — take the share with it.
await prisma.share.deleteMany({ where: { apulodiFileId: event.data.file.id } });
break;
}
// 5. Always acknowledge quickly. A 200 tells APULODI to stop retrying.
return Response.json({ ok: true });
}
Webhooks need a public URL, so for local development put a tunnel in front
of the app and register https://<your-tunnel>/api/webhooks/apulodi:
ngrok http 3000
# or
cloudflared tunnel --url http://localhost:3000
The dashboard shows every delivery with its HTTP status and a Redeliver button — delete a file from the dashboard and watch the event arrive.
Step 8 — A dashboard for your shares
A listing page ties it together, and shows the one SDK call left unused:
apulodi.files.delete(). Deleting a share calls it and removes the row —
and because of the webhook you just built, cleanup works even if this page
errors halfway through:
// app/shares/page.tsx
import Link from "next/link";
import { revalidatePath } from "next/cache";
import { apulodi } from "@/lib/apulodi";
import { prisma } from "@/lib/db";
async function deleteShare(formData: FormData) {
"use server";
const slug = String(formData.get("slug"));
const share = await prisma.share.findUnique({ where: { slug } });
if (!share) return;
await apulodi.files.delete(share.apulodiFileId); // fires file.deleted
await prisma.share.delete({ where: { slug } }); // webhook is the safety net
revalidatePath("/shares");
}
export default async function SharesPage() {
const shares = await prisma.share.findMany({
orderBy: { createdAt: "desc" },
take: 50,
});
return (
<main className="mx-auto flex min-h-screen max-w-2xl flex-col gap-4 p-6">
<h1 className="text-2xl font-bold">Your shares</h1>
<Link href="/" className="text-sm underline">← Upload something new</Link>
<ul className="divide-y rounded-lg border">
{shares.map((share) => (
<li key={share.id} className="flex items-center justify-between gap-3 p-3">
<div className="min-w-0">
<Link href={`/s/${share.slug}`} className="block truncate font-medium underline">
{share.filename}
</Link>
<p className="text-xs text-gray-500">
{share.size} bytes · {share.downloads} downloads
</p>
</div>
<form action={deleteShare}>
<input type="hidden" name="slug" value={share.slug} />
<button
type="submit"
className="rounded-md border px-3 py-1 text-sm hover:bg-red-50"
>
Delete
</button>
</form>
</li>
))}
{shares.length === 0 && (
<li className="p-6 text-center text-sm text-gray-500">No shares yet.</li>
)}
</ul>
</main>
);
}
Where to take it next
The skeleton is done; the interesting decisions are all yours now:
- Users and ownership. Add a
Usermodel to the schema, putuserIdonShare, and scope every query withwhere: { userId }. The webhook is already tenant-safe — events carry the file ID, and you own the mapping. - Expiring shares. Store an
expiresAtonShareand check it on the share page. The underlying download URL is already short-lived; this just hides the door entirely. - Password-protected shares. Hash a password into the row and gate the
download route — ten lines with
node:crypto. - Big files from the browser. For videos and archives, skip buffering through your route handler: mint the presigned upload on your server and PUT the bytes straight from the browser. The multipart guide covers the session API.
- Video and audio. The same
transform()call transcodes video to MP4 or extracts poster frames — the share page needs no changes to benefit.
A production checklist
-
APULODI_API_KEYandAPULODI_WEBHOOK_SECRETin your secret manager, never in git (.env.localis already ignored — keep it that way) - Webhook signature verified over the raw body on every delivery
- Deduplication on
event.idbacked by the database, not memory - Handlers acknowledge in under a second; slow work goes to a queue
- Upload size and content-type validated before the bytes move
- Failed webhook deliveries monitored; the dashboard redelivers them
Go deeper
The quickstart gets you from zero to a first upload in any stack. The files API reference documents every parameter used above, and the webhooks guide lists every event type your receiver can switch on. If you build something with this tutorial — or get stuck on a step — that is genuinely the best feedback we can get.