Build a file-sharing app with Next.js, Drizzle 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.
- Drizzle + 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
local.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 20 or newer (Next.js and
better-sqlite3both need it; the SDK uses the globalfetch) - 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 one runtime dependency this tutorial needs — the APULODI SDK:
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:
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
drizzle-orm— the typed query builder; the schema is TypeScriptbetter-sqlite3— the SQLite driver: one native binding, nothing elsedrizzle-kit— the CLI that creates tables from the schema (push) and browses the data (studio)
Point Drizzle at the schema and the database file:
// 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
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):
// 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:
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:
// 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.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:
Prerequisite: this route assumes Step 2's tables exist. If the upload fails with
no such table: shares, runnpx drizzle-kit push— it createslocal.dband every table in the schema in one command, and it is safe to re-run at any point in the tutorial.
// 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:
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);
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:
// 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:
// 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:
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.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:
// 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:
// 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:
- 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.