← All posts

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

September 21, 202616 min readTutorialsNext.jsDrizzle

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 one runtime dependency this tutorial needs — the APULODI SDK:

bash
npm install @apulodi/sdk

If npm crashes with Cannot read properties of null (reading 'edgesOut') — that is a known npm bug in its dependency-tree builder (npm/cli#8261), not a problem with these packages. Reset the half-built tree and retry: rm -rf node_modules package-lock.json && npm cache verify && npm install. If it persists, update npm itself (npm install -g npm@latest — versions after 11.3.0 contain the fix), or install the packages one at a time.

Step 2 — Set up Drizzle 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 Drizzle queries it through plain TypeScript — no schema DSL, no generated client, no engine binaries.

Install the ORM, the driver, and the schema toolkit, pinned to the exact versions this tutorial is verified against:

bash
npm install drizzle-orm@0.45.2 better-sqlite3@13.0.3
npm install drizzle-kit@0.31.10 @types/better-sqlite3@9.6.0 --save-dev

Point Drizzle at the schema and the database file:

ts
// drizzle.config.ts
import "dotenv/config";
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  schema: "./lib/db/schema.ts",
  out: "./drizzle",
  dialect: "sqlite",
  dbCredentials: { url: process.env.DB_FILE_NAME! },
});
env
# .env
DB_FILE_NAME=local.db

create-next-app ignores .env* in its generated .gitignore — keep it that way once API keys arrive in Step 3.

Now model the app. A share owns one APULODI file, has a random slug for its public URL, and counts downloads. A processed_events table records webhook event IDs so we can deduplicate deliveries later (APULODI retries until you answer 200, so handlers must be idempotent):

ts
// lib/db/schema.ts
import { randomUUID } from "node:crypto";
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";

export const shares = sqliteTable(
  "shares",
  {
    id: text("id").primaryKey().$defaultFn(() => randomUUID()),
    slug: text("slug").notNull().unique(),
    apulodiFileId: text("apulodi_file_id").notNull().unique(),
    filename: text("filename").notNull(),
    contentType: text("content_type").notNull(),
    size: integer("size").notNull(),
    downloads: integer("downloads").notNull().default(0),
    createdAt: integer("created_at", { mode: "timestamp_ms" })
      .notNull()
      .$defaultFn(() => new Date()),
  },
  (table) => [index("shares_created_at_idx").on(table.createdAt)],
);

export const processedEvents = sqliteTable("processed_events", {
  id: text("id").primaryKey(), // the APULODI event id, e.g. "evt_..."
  createdAt: integer("created_at", { mode: "timestamp_ms" })
    .notNull()
    .$defaultFn(() => new Date()),
});

That is the entire model — fully typed from here on, because it is TypeScript.

Create the database and the tables:

bash
npx drizzle-kit push

This creates local.db with both tables in one command. No migration files to write, no codegen step. If you ever change the schema, run push again; npx drizzle-kit studio opens a browser UI over the data when you want to peek.

Drizzle has one sharp edge in Next.js: hot reloading creates a new database connection for every module evaluation until you run out of file handles. The standard fix is a singleton on the global object:

ts
// lib/db/index.ts
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import * as schema from "./schema";

const globalForDb = globalThis as unknown as { conn?: Database.Database };

// One connection across hot reloads — a fresh one per module evaluation
// would exhaust file handles in dev.
const conn = globalForDb.conn ?? new Database(process.env.DB_FILE_NAME!);
if (process.env.NODE_ENV !== "production") globalForDb.conn = conn;

export const db = drizzle({ client: conn, schema });

Everything below imports db from @/lib/db.

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:

Prerequisite: this route assumes Step 2's tables exist. If the upload fails with no such table: shares, run npx drizzle-kit push — it creates local.db and every table in the schema in one command, and it is safe to re-run at any point in the tutorial.

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 { db } from "@/lib/db";
import { shares } from "@/lib/db/schema";

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 db
      .insert(shares)
      .values({
        slug: randomBytes(8).toString("base64url"),
        apulodiFileId: uploaded.id,
        filename: uploaded.filename,
        contentType: uploaded.contentType,
        size: uploaded.size,
      })
      .returning({ slug: shares.slug });

    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);
  const [fileName, setFileName] = useState<string | null>(null);

  async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();
    // Capture the form element before awaiting — React nulls out
    // currentTarget once the event dispatch has ended, and anything async
    // (like this fetch) runs after that.
    const formElement = event.currentTarget;
    const form = new FormData(formElement);
    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);
      formElement.reset();
      setFileName(null); // reset() clears the input but fires no change event
    }
    setUploading(false);
  }

  return (
    <main className="mx-auto flex min-h-screen max-w-md flex-col justify-center gap-6 p-6">
      <div>
        <h1 className="text-2xl font-bold tracking-tight">Share a file</h1>
        <p className="mt-1 text-sm text-gray-500">
          Upload once — anyone with the link downloads it.
        </p>
      </div>

      <form
        onSubmit={onSubmit}
        className="flex flex-col gap-4 rounded-2xl border border-gray-200 bg-white p-6 shadow-sm"
      >
        <label
          className={`flex cursor-pointer flex-col items-center justify-center gap-1 rounded-xl border-2 border-dashed px-4 py-10 text-center transition-colors ${
            fileName
              ? "border-emerald-400 bg-emerald-50/50"
              : "border-gray-300 hover:border-gray-400 hover:bg-gray-50"
          }`}
        >
          <span className="text-3xl">{fileName ? "📄" : "📎"}</span>
          <span className="max-w-full truncate text-sm font-medium text-gray-700">
            {fileName ?? "Choose a file"}
          </span>
          <span className="text-xs text-gray-400">
            {fileName ? "Click to choose a different file" : "Up to 50 MB — any type"}
          </span>
          <input
            type="file"
            name="file"
            required
            className="sr-only"
            onChange={(e) => setFileName(e.target.files?.[0]?.name ?? null)}
          />
        </label>
        <button
          type="submit"
          disabled={uploading}
          className="rounded-lg bg-black px-4 py-2.5 font-medium text-white transition-colors hover:bg-gray-800 disabled:cursor-not-allowed disabled:opacity-50"
        >
          {uploading ? "Uploading…" : "Upload"}
        </button>
      </form>

      {error && (
        <p className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
          {error}
        </p>
      )}

      {shareUrl && (
        <div className="rounded-2xl border border-emerald-200 bg-emerald-50 p-5">
          <p className="text-sm font-medium text-emerald-800">Share this link:</p>
          <Link
            href={shareUrl}
            className="mt-1 block truncate font-mono text-sm text-emerald-700 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 { eq } from "drizzle-orm";
import { apulodi } from "@/lib/apulodi";
import { db } from "@/lib/db";
import { shares } from "@/lib/db/schema";

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 db
    .select()
    .from(shares)
    .where(eq(shares.slug, slug))
    .limit(1);
  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">
      <div className="w-full rounded-2xl border border-gray-200 bg-white p-8 text-center shadow-sm">
        {thumbnail ? (
          // eslint-disable-next-line @next/next/no-img-element
          <img
            src={thumbnail}
            alt={share.filename}
            className="mx-auto max-h-72 rounded-xl"
          />
        ) : (
          <div className="mx-auto flex h-40 w-40 items-center justify-center rounded-2xl bg-gray-100 text-5xl">
            📄
          </div>
        )}

        <h1 className="mt-5 break-all text-xl font-semibold">{share.filename}</h1>
        <p className="mt-1 text-sm text-gray-500">
          {formatBytes(share.size)} · {share.downloads} downloads
        </p>

        <a
          href={`/api/s/${share.slug}/download`}
          className="mt-6 block w-full rounded-lg bg-black px-4 py-2.5 font-medium text-white transition-colors hover:bg-gray-800"
        >
          Download
        </a>
        <p className="mt-3 text-xs text-gray-400">
          Secure link — the URL expires after 5 minutes.
        </p>
      </div>
    </main>
  );
}

And the download route:

ts
// app/api/s/[slug]/download/route.ts
import { NextResponse } from "next/server";
import { eq, sql } from "drizzle-orm";
import { apulodi } from "@/lib/apulodi";
import { db } from "@/lib/db";
import { shares } from "@/lib/db/schema";

export async function GET(
  _request: Request,
  { params }: { params: Promise<{ slug: string }> },
) {
  const { slug } = await params;
  const [share] = await db
    .select()
    .from(shares)
    .where(eq(shares.slug, slug))
    .limit(1);
  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 db
    .update(shares)
    .set({ downloads: sql`${shares.downloads} + 1` })
    .where(eq(shares.slug, slug));

  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.

Two things must exist before events can flow: a publicly reachable URL for your dev server, and a webhook registration that points at it.

1. Put a tunnel in front of your dev server. APULODI's servers must be able to POST to your machine, and localhost is not reachable from the outside. In a second terminal:

bash
ngrok http 3000
# or
cloudflared tunnel --url http://localhost:3000

Note the public URL it prints — with Cloudflare it looks like https://random-words-1234.trycloudflare.com.

2. Register the webhook in the dashboard (Project → Webhooks). The endpoint URL is your tunnel URL plus the receiver path — for example https://random-words-1234.trycloudflare.com/api/webhooks/apulodi. Both tunnel tools rotate free URLs on restart, so if the URL changes, update the webhook in the dashboard. The signing secret is shown exactly once — it goes in .env.local:

env
# .env.local
APULODI_WEBHOOK_SECRET=whsec_your_secret_here

3. Write the receiver. The handler 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 the processed_events table is for), then dispatch:

ts
// app/api/webhooks/apulodi/route.ts
import { verifyWebhookSignature } from "@apulodi/sdk";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db";
import { processedEvents, shares } from "@/lib/db/schema";
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.
  //    The event id is the primary key, so a repeat insert does nothing.
  const inserted = await db
    .insert(processedEvents)
    .values({ id: event.id })
    .onConflictDoNothing()
    .returning({ id: processedEvents.id });
  if (inserted.length === 0) {
    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 db
        .delete(shares)
        .where(eq(shares.apulodiFileId, event.data.file.id));
      break;
  }

  // 5. Always acknowledge quickly. A 200 tells APULODI to stop retrying.
  return Response.json({ ok: true });
}

The dashboard shows every delivery with its HTTP status and a Redeliver button — delete a file from the dashboard and watch the event arrive. When you deploy for real, the endpoint URL becomes your production origin (https://your-app.com/api/webhooks/apulodi) — no tunnel needed.

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 { desc, eq } from "drizzle-orm";
import { apulodi } from "@/lib/apulodi";
import { db } from "@/lib/db";
import { shares } from "@/lib/db/schema";

async function deleteShare(formData: FormData) {
  "use server";
  const slug = String(formData.get("slug"));
  const [share] = await db
    .select()
    .from(shares)
    .where(eq(shares.slug, slug))
    .limit(1);
  if (!share) return;

  await apulodi.files.delete(share.apulodiFileId); // fires file.deleted
  await db.delete(shares).where(eq(shares.slug, slug)); // webhook is the safety net
  revalidatePath("/shares");
}

export default async function SharesPage() {
  const rows = await db
    .select()
    .from(shares)
    .orderBy(desc(shares.createdAt))
    .limit(50);

  return (
    <main className="mx-auto flex min-h-screen max-w-2xl flex-col gap-6 p-6">
      <div className="flex items-center justify-between gap-4">
        <div>
          <h1 className="text-2xl font-bold tracking-tight">Your shares</h1>
          <p className="mt-1 text-sm text-gray-500">
            {rows.length === 0 ? "Nothing uploaded yet." : `${rows.length} total`}
          </p>
        </div>
        <Link
          href="/"
          className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm font-medium shadow-sm transition-colors hover:bg-gray-50"
        >
          ← Upload something new
        </Link>
      </div>

      <ul className="flex flex-col gap-3">
        {rows.map((share) => (
          <li
            key={share.id}
            className="flex items-center justify-between gap-3 rounded-xl border border-gray-200 bg-white p-4 shadow-sm"
          >
            <div className="min-w-0">
              <Link
                href={`/s/${share.slug}`}
                className="block truncate font-medium underline decoration-gray-300 underline-offset-2 hover:decoration-gray-800"
              >
                {share.filename}
              </Link>
              <p className="mt-0.5 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 border-red-200 px-3 py-1 text-sm text-red-600 transition-colors hover:bg-red-50"
              >
                Delete
              </button>
            </form>
          </li>
        ))}
        {rows.length === 0 && (
          <li className="rounded-xl border border-dashed border-gray-300 p-10 text-center text-sm text-gray-500">
            No shares yet — upload your first file.
          </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.