← All posts

Build a file-sharing app with Next.js, Prisma and APULODI

September 20, 202616 min readTutorialsNext.jsPrisma

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:

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

Step 1 — Create the Next.js app

Start from a clean App Router project with TypeScript, Tailwind and ESLint already wired up:

bash
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:

bash
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:

bash
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
// 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:

bash
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:

ts
// 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
# .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:

ts
// 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:

ts
// 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:

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:

tsx
// 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:

tsx
// 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:

ts
// 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
# .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:

ts
// 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:

bash
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:

tsx
// 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:

A production checklist

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.