How to Add File Uploads With Claude Code (Supabase Storage)
Build secure user file and image uploads end to end with Claude Code: private Supabase Storage buckets, per-user RLS, signed URLs, and an upload UI wired through a type-safe oRPC API.
Arrête de tout configurer. Place à la construction.
Des templates SaaS avec orchestration IA.
File uploads look simple until a user downloads someone else's invoice by guessing a URL. This guide wires up secure user uploads end to end: a private Supabase Storage bucket, per-user row level security, signed upload and download URLs, and an upload UI that runs through a type-safe oRPC API. Claude Code writes most of it, and the security lives in the database where it belongs.
Arrête de tout configurer. Place à la construction.
Des templates SaaS avec orchestration IA.
What You're Building
The flow has four moving parts, and each one has a job. The bucket holds the bytes. RLS decides who can touch which bytes. Signed URLs hand out temporary access without making anything public. The oRPC layer gives your frontend a typed function to call instead of a raw fetch.
Here is the full path a file takes. The browser picks a file and asks your server for permission. The server checks the file type and size, then returns a one-time signed upload token pointing at a path inside the user's private folder. The browser uploads the bytes straight to Supabase using that token. Later, when the app needs to show the file, the server generates a short-lived signed download URL. At no point is the bucket public, and at no point does a large file stream through your Next.js function.
Tell Claude Code the shape of this before it writes anything. A one-line brief in CLAUDE.md keeps it on the secure path instead of reaching for a public bucket:
## File uploads
- Bucket "user-uploads" is PRIVATE. Never make it public.
- Files live under {userId}/{uuid}-{filename}. First folder segment is the owner.
- RLS on storage.objects enforces per-user access. Do not skip it.
- Uploads use signed upload URLs. Reads use signed download URLs (60 min).
- The API layer is oRPC with Zod. No raw fetch from components.Create a Private Storage Bucket
Storage buckets are rows in the storage.buckets table, so you create one with SQL and check it into a migration. Keeping the bucket private is the single most important line here. A private bucket returns nothing to an anonymous request, which is exactly what you want for user documents and images.
This migration also sets a file size limit and an allowed MIME type list at the bucket level. That gives you a hard server-side ceiling even before your own validation runs. Ask Claude to create the migration file, then paste this in:
-- supabase/migrations/20260714000000_user_uploads_bucket.sql
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values (
'user-uploads',
'user-uploads',
false,
5242880, -- 5 MB
array['image/png', 'image/jpeg', 'image/webp', 'application/pdf']
)
on conflict (id) do nothing;The file_size_limit is in bytes, so 5242880 is 5 MB. The allowed_mime_types array means Supabase itself rejects a .exe before it ever lands in the bucket. These are enforced by Storage, not by your app, so they hold even if a request skips your API.
Lock It Down With Per-User RLS
This is where the actual security lives. Every object in Supabase Storage is a row in storage.objects, and that table honors row level security like any other. The trick is that the first folder segment of each file path is the owner's user id, so a policy can compare it to the id of whoever is making the request.
The storage.foldername(name) function splits a path like abc-123/photo.png into an array of folder parts. Taking element [1] gives you the first segment, which is the user id. Comparing it to auth.uid() means a request only succeeds when the caller owns that folder. Add one policy each for reading, uploading, and deleting:
-- supabase/migrations/20260714000100_user_uploads_rls.sql
-- Read only your own files
create policy "Users read own files"
on storage.objects for select
to authenticated
using (
bucket_id = 'user-uploads'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);
-- Upload only into your own folder
create policy "Users upload own files"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'user-uploads'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);
-- Delete only your own files
create policy "Users delete own files"
on storage.objects for delete
to authenticated
using (
bucket_id = 'user-uploads'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);Because these policies run inside PostgreSQL, they are the real boundary. Even if a bug in your API sends a path pointing at another user's folder, the insert or delete fails at the database. That is the point of putting access control in RLS instead of app code: the app can be wrong and the data still stays safe.
The Supabase Clients
You need two Supabase clients: one for the server that reads the session from cookies, and one for the browser. Next.js 16 makes cookies() async, so the server client is an async function. Claude Code gets this right when it has read the Next.js 16 docs, but it is worth checking the await cookies() call by hand.
The server client is what your oRPC handlers use. It carries the logged-in user's session, so every Storage call it makes runs under that user's RLS policies:
// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Called from a Server Component. Safe to ignore when proxy.ts refreshes sessions.
}
},
},
}
);
}The browser client handles the one thing that must happen in the browser: pushing the file bytes to Supabase with the signed upload token. It never sees a service role key, only the public anon key:
// lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}Keep Sessions Fresh With proxy.ts
Supabase auth tokens expire, and something has to refresh them on each request or your uploads start failing with an unauthorized error mid-session. In Next.js 16 that job lives in proxy.ts at the project root, not the old middleware.ts. Claude Code still reaches for middleware.ts out of habit, so this is a common correction.
The proxy calls getUser() on every matched request, which refreshes the session cookie when needed. Everything else about it is boilerplate:
// proxy.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function proxy(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
response = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
);
},
},
}
);
await supabase.auth.getUser();
return response;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};The Upload API With oRPC and Zod
Now the type-safe layer. Instead of the browser calling a raw endpoint, it calls typed functions that share their input and output types with the server. oRPC with Zod gives you that, and the validation is not decoration: it is the server-side check that a client cannot lie about a file type.
Start with an authenticated base procedure. This middleware creates the server Supabase client, pulls the current user, and rejects anyone who is not logged in. Every real procedure builds on top of it, so the auth check is never forgotten:
// server/base.ts
import { os } from "@orpc/server";
import { createClient } from "@/lib/supabase/server";
export const authed = os.use(async ({ next }) => {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
throw new Error("Unauthorized");
}
return next({ context: { supabase, user } });
});The first real procedure mints a signed upload URL. It validates the filename and forces the content type to be one of the four allowed values through a Zod enum. Then it builds a path under the user's own folder using a random UUID so two files with the same name never collide, and asks Storage for a one-time upload token:
// server/uploads.ts
import { z } from "zod";
import { randomUUID } from "crypto";
import { authed } from "./base";
const CONTENT_TYPES = [
"image/png",
"image/jpeg",
"image/webp",
"application/pdf",
] as const;
export const getUploadUrl = authed
.input(
z.object({
filename: z.string().min(1).max(200),
contentType: z.enum(CONTENT_TYPES),
})
)
.handler(async ({ input, context }) => {
const { supabase, user } = context;
// Strip anything that could break a path
const safeName = input.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
const path = `${user.id}/${randomUUID()}-${safeName}`;
const { data, error } = await supabase.storage
.from("user-uploads")
.createSignedUploadUrl(path);
if (error) {
throw new Error(error.message);
}
return { path: data.path, token: data.token };
});The second procedure lists the current user's files and turns each one into a signed download URL that lives for one hour. Because the bucket is private, these signed URLs are the only way the browser can render an image. Listing the user's own folder plus the RLS read policy means nobody else's files can ever appear here:
// server/uploads.ts (continued)
export const listFiles = authed.handler(async ({ context }) => {
const { supabase, user } = context;
const { data, error } = await supabase.storage
.from("user-uploads")
.list(user.id, {
sortBy: { column: "created_at", order: "desc" },
});
if (error) {
throw new Error(error.message);
}
const files = await Promise.all(
(data ?? []).map(async (obj) => {
const path = `${user.id}/${obj.name}`;
const { data: signed } = await supabase.storage
.from("user-uploads")
.createSignedUrl(path, 60 * 60); // 1 hour
return { name: obj.name, path, url: signed?.signedUrl ?? null };
})
);
return { files };
});The third procedure deletes a file. It takes a path from the client, but that is safe: the RLS delete policy re-checks ownership in the database, so passing another user's path just fails. Defense in depth means the client input is never trusted on its own:
// server/uploads.ts (continued)
export const deleteFile = authed
.input(z.object({ path: z.string().min(1) }))
.handler(async ({ input, context }) => {
const { supabase } = context;
const { error } = await supabase.storage
.from("user-uploads")
.remove([input.path]);
if (error) {
throw new Error(error.message);
}
return { ok: true };
});Collect the three procedures into a router. This object is what gives the client its types:
// server/router.ts
import { getUploadUrl, listFiles, deleteFile } from "./uploads";
export const router = { getUploadUrl, listFiles, deleteFile };Serve the Router and Build the Client
The router needs one route handler to answer requests. oRPC ships a fetch handler that plugs straight into a Next.js catch-all route:
// app/api/rpc/[[...rest]]/route.ts
import { RPCHandler } from "@orpc/server/fetch";
import { router } from "@/server/router";
const handler = new RPCHandler(router);
async function handle(request: Request) {
const { response } = await handler.handle(request, { prefix: "/api/rpc" });
return response ?? new Response("Not found", { status: 404 });
}
export const GET = handle;
export const POST = handle;On the browser side, the oRPC client imports only the router type, never its code, so no server logic ships to the browser. Calling orpc.getUploadUrl(...) is now fully typed from input to output:
// lib/orpc.ts
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import type { RouterClient } from "@orpc/server";
import type { router } from "@/server/router";
const link = new RPCLink({
url:
typeof window !== "undefined"
? `${window.location.origin}/api/rpc`
: "/api/rpc",
});
export const orpc: RouterClient<typeof router> = createORPCClient(link);The Upload UI
The client component ties it together. It validates the file in the browser for fast feedback, asks the server for an upload token, pushes the bytes straight to Supabase with uploadToSignedUrl, then refreshes the list. The browser-side checks are a courtesy for the user; the real limits are still the bucket config and RLS.
Note the two layers of the flow. The typed orpc.getUploadUrl call returns a token and a path, and the Supabase browser client uses them to upload without ever holding a long-lived credential:
// components/file-uploader.tsx
"use client";
import { useState, useEffect } from "react";
import { createClient } from "@/lib/supabase/client";
import { orpc } from "@/lib/orpc";
const ALLOWED = [
"image/png",
"image/jpeg",
"image/webp",
"application/pdf",
] as const;
type Allowed = (typeof ALLOWED)[number];
const MAX_BYTES = 5 * 1024 * 1024;
type StoredFile = { name: string; path: string; url: string | null };
export function FileUploader() {
const supabase = createClient();
const [files, setFiles] = useState<StoredFile[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function refresh() {
const { files } = await orpc.listFiles();
setFiles(files);
}
useEffect(() => {
refresh();
}, []);
async function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setError(null);
if (!ALLOWED.includes(file.type as Allowed)) {
setError("Only PNG, JPEG, WebP, or PDF files are allowed.");
return;
}
if (file.size > MAX_BYTES) {
setError("Files must be 5 MB or smaller.");
return;
}
setBusy(true);
try {
const { path, token } = await orpc.getUploadUrl({
filename: file.name,
contentType: file.type as Allowed,
});
const { error: uploadError } = await supabase.storage
.from("user-uploads")
.uploadToSignedUrl(path, token, file);
if (uploadError) throw uploadError;
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Upload failed.");
} finally {
setBusy(false);
e.target.value = "";
}
}
async function onDelete(path: string) {
await orpc.deleteFile({ path });
await refresh();
}
return (
<div className="space-y-4">
<input
type="file"
accept={ALLOWED.join(",")}
onChange={onChange}
disabled={busy}
className="block text-sm"
/>
{busy && <p className="text-sm text-muted-foreground">Uploading...</p>}
{error && <p className="text-sm text-red-500">{error}</p>}
<ul className="grid grid-cols-2 gap-3">
{files.map((file) => (
<li key={file.path} className="rounded-lg border p-2">
{file.url && /\.(png|jpe?g|webp)$/i.test(file.name) ? (
<img
src={file.url}
alt={file.name}
className="h-32 w-full rounded object-cover"
/>
) : (
<a href={file.url ?? "#"} className="text-sm underline">
{file.name}
</a>
)}
<button
onClick={() => onDelete(file.path)}
className="mt-2 block text-xs text-red-500"
>
Delete
</button>
</li>
))}
</ul>
</div>
);
}Drop the component into a page and you have a working, secure uploader. The page is a plain Server Component:
// app/files/page.tsx
import { FileUploader } from "@/components/file-uploader";
export default function FilesPage() {
return (
<main className="mx-auto max-w-2xl py-12">
<h1 className="mb-6 text-2xl font-bold">Your Files</h1>
<FileUploader />
</main>
);
}Run the Quality Gates
Before you ship, run the same two checks that catch most upload bugs. Type errors here usually mean a mismatch between the Zod input and what the component sends, which is exactly the class of bug the typed API is meant to surface early.
npx tsc --noEmit
npm run buildThen test the real thing, not just the types. Log in as one user, upload an image, confirm it renders. Then log in as a second user and confirm you cannot see the first user's files and cannot delete them by pasting a path. If RLS is wired correctly, both attempts fail at the database. Ask Claude to walk the flow with you:
claude "run tsc --noEmit and the build, then help me test uploads: verify a second user cannot read or delete the first user's files"Zero type errors, a clean build, and a passing cross-user access test are the bar. The access test is the one that matters most, because a type error is annoying and a broken RLS policy is a data leak.
Where the $29 Code Kit Fits
Claude Code writes this feature well when you brief it. The friction is doing that briefing for every feature, then remembering to run type checks, lint, a build, and an actual cross-user security test each time before you trust the result. The $29 Code Kit ($29 one-time, no subscription) is a harness on top of Claude Code that runs that loop for you: it plans the feature, builds it, evaluates the output, tests it, and holds the work at a quality gate of zero type errors, zero lint errors, and a clean build before anything ships. It does not replace Claude Code (you still need a paid Anthropic plan for that), it just removes the part where you manually shepherd every feature through the same checklist. For an upload flow where a skipped RLS policy is the difference between working and leaking, having that gate run automatically is worth the price.
Arrête de tout configurer. Place à la construction.
Des templates SaaS avec orchestration IA.
Posted by @speedy_devv
Arrête de tout configurer. Place à la construction.
Des templates SaaS avec orchestration IA.
Construire une API type-safe avec Claude Code (oRPC + Zod)
Comment construire une couche API entièrement type-safe dans Next.js 16 avec oRPC et Zod, avec Claude Code, pour que les changements de schéma cassent le build plutôt que la prod.
How to Run Background Jobs with Claude Code and Inngest
Move slow work off the request path. Build a durable Inngest job with retries, steps, and concurrency, triggered straight from your oRPC endpoints in Next.js 16.