Build This Now
Build This Now
Was ist der Claude Code?Claude Code installierenClaude Code Native InstallerDein erstes Claude Code-Projekt
Claude Code v2.1.122 Release NotesClaude Code Dynamic Workflows: 1.000 Subagents auf einer echten Codebase orchestrierenClaude Code Best PracticesClaude Opus 4.7 Best PracticesClaude Code auf einem VPSGit-IntegrationClaude Code ReviewClaude Code WorktreesClaude Code Remote ControlClaude Code ChannelsChannels, Routines, Teleport, DispatchGeplante Aufgaben mit Claude CodeClaude Code BerechtigungenClaude Code Auto-ModusStripe-Zahlungen mit Claude Code einbauenFeedback-LoopsTodo-WorkflowsClaude Code TasksProjekt-TemplatesClaude Code Preise und Token-NutzungClaude Code Preise: Was du wirklich zahlstClaude Code Ultra ReviewEine Next.js-App mit Claude Code bauenClaude Code With Supabase: Database, Auth, RLSVercel deepsec with Claude CodeTest-Driven Development with Claude CodeSo baust du ein SaaS-MVP mit Claude CodeAuthentifizierung mit Claude Code einbauen (Supabase Auth)Transaktions-E-Mails mit Claude Code einbauen (Resend + React Email)Eine typsichere API mit Claude Code bauen (oRPC + Zod)How to Add File Uploads With Claude Code (Supabase Storage)How to Run Background Jobs with Claude Code and InngestHow to Build an Admin Dashboard With Claude CodeHow to Add Full-Text Search With Claude Code (Postgres)Agentic Commerce: Wie du eine App baust, für die KI-Agents bezahlen könnenClaude Code 1M Context in Practice: When Bigger Isn't BetterClaude Code GitHub Actions Setup Guide (@claude + Cron)Claude Code Headless Mode: The Definitive Guide to claude -pClaude Code Max Plan vs API Cost: Break-Even GuideClaude Code Prompt Caching: The Token Discount Most People Never Turn OnWas es 2026 kostet, ein SaaS mit Claude Code zu bauenRun a Team of AI Agents in Parallel with Git WorktreesPrompt Injection in Coding Agents: How to Not Get Pwned
speedy_devvkoen_salo
Blog/Handbook/Workflow/How to Add File Uploads With Claude Code (Supabase Storage)

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.

Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.

SaaS-Builder-Vorlagen mit KI-Orchestrierung.

Sehen Sie, was wir für Unternehmen bauen →
speedy_devvWritten by speedy_devvPublished Jul 14, 202610 min readHandbook hubWorkflow index

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.


Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.

SaaS-Builder-Vorlagen mit KI-Orchestrierung.

Sehen Sie, was wir für Unternehmen bauen →

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 build

Then 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.

Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.

SaaS-Builder-Vorlagen mit KI-Orchestrierung.

Sehen Sie, was wir für Unternehmen bauen →

Posted by @speedy_devv

Continue in Workflow

  • Agentic Commerce: Wie du eine App baust, für die KI-Agents bezahlen können
    Ein Guide in einfachem Deutsch zu Agentic Commerce im Jahr 2026: Was x402, ACP und das Machine Payments Protocol tun, plus eine Wochenend-Anleitung, um eine bezahlte API auszuliefern, von der KI-Agents kaufen können.
  • Claude Code Best Practices
    Fünf Gewohnheiten trennen Entwickler, die mit Claude Code liefern: PRDs, modulare CLAUDE.md-Regeln, Custom-Slash-Commands, /clear-Resets und eine System-Evolutions-Denkweise.
  • Claude Code Auto-Modus
    Ein zweites Sonnet-Modell prüft jeden Claude Code-Tool-Aufruf, bevor er ausgeführt wird. Was der Auto-Modus blockiert, was er erlaubt, und die Erlaubnisregeln, die er in deine Einstellungen schreibt.
  • Channels, Routines, Teleport, Dispatch
    Die vier Claude-Code-Features, die Anthropic im März und April 2026 ausgeliefert hat und die die CLI in eine ereignisgesteuerte Koordinationsschicht über Handy, Web und Desktop verwandeln.
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • How to Build an Admin Dashboard With Claude Code
    Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.

More from Handbook

  • Grundlagen für Agenten
    Fünf Möglichkeiten, spezialisierte Agenten in Claude Code zu erstellen: Aufgaben-Unteragenten, .claude/agents YAML, benutzerdefinierte Slash-Befehle, CLAUDE.md Personas und perspektivische Aufforderungen.
  • Agent-Harness-Engineering
    Der Harness ist jede Schicht rund um deinen KI-Agenten, außer dem Modell selbst. Lern die fünf Steuerungshebel, das Constraint-Paradoxon und warum das Harness-Design die Performance des Agenten mehr bestimmt als das Modell.
  • Agenten-Muster
    Orchestrator, Fan-out, Validierungskette, Spezialistenrouting, Progressive Verfeinerung und Watchdog. Sechs Orchestrierungsformen, um Claude Code Sub-Agenten zu verdrahten.
  • Agent Teams Best Practices
    Bewährte Muster für Claude Code Agent Teams. Kontextreiche Spawn-Prompts, richtig bemessene Aufgaben, Datei-Eigentümerschaft, Delegate-Modus und Fixes für v2.1.33-v2.1.45.

Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.

SaaS-Builder-Vorlagen mit KI-Orchestrierung.

Sehen Sie, was wir für Unternehmen bauen →

Eine typsichere API mit Claude Code bauen (oRPC + Zod)

So baust du eine vollständig typsichere API-Schicht in Next.js 16 mit oRPC und Zod, mit Claude Code, sodass Schema-Änderungen den Build brechen statt der Produktion.

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.

On this page

What You're Building
Create a Private Storage Bucket
Lock It Down With Per-User RLS
The Supabase Clients
Keep Sessions Fresh With proxy.ts
The Upload API With oRPC and Zod
Serve the Router and Build the Client
The Upload UI
Run the Quality Gates
Where the $29 Code Kit Fits

Hören Sie auf zu konfigurieren. Fangen Sie an zu bauen.

SaaS-Builder-Vorlagen mit KI-Orchestrierung.

Sehen Sie, was wir für Unternehmen bauen →