Drag-and-drop upload zone with floating file icons and cloud storage

    File Uploads in Lovable Forms: Drag & Drop, Supabase Storage, RLS and Signed URLs

    Till FreitagTill Freitag19. April 20265 min read
    Till Freitag

    TL;DR: „Drag & drop UI with react-dropzone, secure upload to Supabase Storage, private buckets with RLS and signed URLs for time-limited access – the complete file upload setup."

    — Till Freitag

    📌 Lovable Forms Series · Part 6 of 6
    The closing chapter: when your form needs to receive applications, contracts, screenshots or PDFs, you need more than <input type="file">. Here's how to do it right.

    File Uploads in Lovable Forms: Drag & Drop, Supabase Storage, RLS and Signed URLs

    File uploads are the classic feature MVPs fail on: either the UX is ugly (browser default), validation is missing (50 MB application PDF crashes the server), or security is wide open (public bucket with every applicant's CV).

    In this part we build the full stack: a slick drag-and-drop UI, client and server validation, secure Supabase Storage buckets with RLS, and time-limited access via signed URLs.


    Architecture

    [react-dropzone UI]
            ↓ (client validation)
    [Supabase Storage upload]
            ↓ (RLS policies)
    [Bucket: form-uploads (private)]
            ↓ (Edge Function)
    [signed URL for sales/HR team]

    Golden rule: uploads ALWAYS land in private buckets. Never public, not even "just briefly".


    Step 1: Bucket + RLS in Lovable Cloud

    In the Supabase console, create a bucket form-uploads. Public off. Then this migration:

    -- Bucket setup (if not via UI)
    insert into storage.buckets (id, name, public)
    values ('form-uploads', 'form-uploads', false)
    on conflict (id) do nothing;
    
    -- RLS: anon users may ONLY upload to their own submission folder
    -- Folder structure: form-uploads/{submission_id}/{filename}
    create policy "anon can upload to own submission folder"
    on storage.objects
    for insert
    to anon
    with check (
      bucket_id = 'form-uploads'
      and (storage.foldername(name))[1] = current_setting('request.jwt.claims', true)::json->>'submission_id'
    );
    
    -- Nobody except service_role may read (downloads via signed URL)
    create policy "no public read"
    on storage.objects
    for select
    to anon, authenticated
    using (false);

    ⚠️ Important: the submission_id comes from a short-lived JWT issued by an Edge Function – not from the frontend. Otherwise anyone could guess IDs.


    Step 2: Upload-token Edge Function

    Before uploading, the frontend grabs an upload token (valid ~10 min) and a fresh submission_id.

    // supabase/functions/issue-upload-token/index.ts
    import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
    import { create } from "https://deno.land/x/djwt@v3.0.1/mod.ts";
    
    const corsHeaders = {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
    };
    
    serve(async (req) => {
      if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
    
      const submissionId = crypto.randomUUID();
      const secret = Deno.env.get("SUPABASE_JWT_SECRET")!;
    
      const key = await crypto.subtle.importKey(
        "raw", new TextEncoder().encode(secret),
        { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
      );
    
      const jwt = await create(
        { alg: "HS256", typ: "JWT" },
        {
          role: "anon",
          submission_id: submissionId,
          exp: Math.floor(Date.now() / 1000) + 600, // 10 min
        },
        key
      );
    
      return new Response(JSON.stringify({ submissionId, uploadToken: jwt }), {
        headers: { ...corsHeaders, "Content-Type": "application/json" },
      });
    });

    Step 3: Drag & Drop UI with react-dropzone

    import { useDropzone } from "react-dropzone";
    import { Upload, X, FileText } from "lucide-react";
    
    const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
    const ACCEPTED = {
      "application/pdf": [".pdf"],
      "image/jpeg": [".jpg", ".jpeg"],
      "image/png": [".png"],
    };
    
    export function FileUploadField({ onChange }: { onChange: (files: UploadedFile[]) => void }) {
      const [files, setFiles] = useState<UploadedFile[]>([]);
    
      const onDrop = useCallback(async (accepted: File[]) => {
        // 1. Get token
        const { data: tokenData } = await supabase.functions.invoke("issue-upload-token");
        const { submissionId, uploadToken } = tokenData;
    
        // 2. Upload
        const uploaded: UploadedFile[] = [];
        for (const file of accepted) {
          const path = `${submissionId}/${crypto.randomUUID()}-${file.name}`;
          const { error } = await supabase.storage
            .from("form-uploads")
            .upload(path, file, {
              headers: { Authorization: `Bearer ${uploadToken}` },
              cacheControl: "3600",
              upsert: false,
            });
          if (error) {
            toast.error(`Upload failed: ${file.name}`);
            continue;
          }
          uploaded.push({ name: file.name, path, size: file.size });
        }
        const next = [...files, ...uploaded];
        setFiles(next);
        onChange(next);
      }, [files, onChange]);
    
      const { getRootProps, getInputProps, isDragActive } = useDropzone({
        onDrop,
        accept: ACCEPTED,
        maxSize: MAX_SIZE,
        multiple: true,
      });
    
      return (
        <div className="space-y-2">
          <div
            {...getRootProps()}
            className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition
              ${isDragActive ? "border-primary bg-primary/5" : "border-muted-foreground/30"}`}
          >
            <input {...getInputProps()} />
            <Upload className="mx-auto h-8 w-8 text-muted-foreground" />
            <p className="mt-2 text-sm">
              {isDragActive ? "Drop to upload" : "Drop PDF, JPG or PNG here, or click"}
            </p>
            <p className="text-xs text-muted-foreground">Max. 10 MB per file</p>
          </div>
    
          {files.map((f, i) => (
            <div key={i} className="flex items-center justify-between rounded border p-2">
              <span className="flex items-center gap-2 text-sm"><FileText className="h-4 w-4" />{f.name}</span>
              <button onClick={() => removeFile(i)}><X className="h-4 w-4" /></button>
            </div>
          ))}
        </div>
      );
    }

    Step 4: Server-side validation (mandatory)

    Client validation is UX only. Real validation happens in the submit Edge Function:

    // supabase/functions/submit-application/index.ts
    const ALLOWED_TYPES = ["application/pdf", "image/jpeg", "image/png"];
    const MAX_BYTES = 10 * 1024 * 1024;
    
    // For every uploaded file:
    const { data: head } = await supabaseAdmin.storage
      .from("form-uploads")
      .info(file.path); // metadata
    
    if (!ALLOWED_TYPES.includes(head.contentType)) {
      await supabaseAdmin.storage.from("form-uploads").remove([file.path]);
      throw new Error("Invalid file type");
    }
    if (head.size > MAX_BYTES) {
      await supabaseAdmin.storage.from("form-uploads").remove([file.path]);
      throw new Error("File too large");
    }

    💡 Bonus for regulated industries: chain a virus scan via ClamAV as a second Edge Function. On infected → delete file + reject submission.


    Step 5: Signed URLs for read access

    When the HR team opens an application from your internal dashboard, never make the bucket public. Instead:

    const { data, error } = await supabaseAdmin.storage
      .from("form-uploads")
      .createSignedUrl(file.path, 60 * 5); // 5 minutes
    
    return data.signedUrl;

    The URL has a one-time token, expires, can't be shared. Perfect for "Click to open the CV".


    Pitfalls

    Problem Cause Fix
    50 MB limit (Supabase Free) Bucket default Upgrade plan or use multi-part upload
    CORS error on direct upload Bucket CORS config missing In Supabase dashboard: Storage → Configuration → Allowed Origins
    User sees bucket listing Wrong RLS policy Explicit for select using (false)
    File lingers after form abort No cleanup Cron Edge Function: delete form-uploads/* older than 24h without DB row
    Mimetype spoof (.jpg = .exe) Only extension checked Server-side: check head.contentType + magic-number check

    Conclusion

    File uploads in Lovable aren't complicated – they just have many layers that all need to be right: UI, token, bucket, RLS, server validation, signed URLs. Skip one and you either have no upload (UI/token) or a data leak (RLS).

    With the stack shown here you get both: nice drag & drop UX and HR-/GDPR-grade security.


    That wraps up the series

    You now have the full spectrum:

    Part Focus
    1 SaaS tools (Tally, Typeform, WorkForms)
    2 Custom build (RHF + zod + Lovable Cloud)
    3 Production-ready best practices
    4 monday.com integration via GraphQL
    5 Smart forms with AI
    6 File uploads & storage (you are here)

    Want the whole series as a consulting workshop? Talk to us – we build form stacks from the first line of code all the way to the CRM entry.

    TeilenLinkedInWhatsAppE-Mail

    Related Articles

    Web form connected to a monday.com board via a GraphQL arrow
    April 19, 20266 min

    Connect Forms to monday.com: Lead Form → Item via GraphQL API

    Part 4 of the Lovable Forms series: how to write Lovable forms directly into monday boards – with GraphQL, an Edge Funct…

    Read more
    Glassmorphic form with an AI sparkle icon and auto-complete suggestions
    April 19, 20265 min

    Smart Forms with AI in Lovable: Auto-Complete, AI Validation & Conversational Forms

    Part 5 of the Lovable Forms series: how to upgrade forms with the Lovable AI Gateway – auto-complete, AI-driven validati…

    Read more
    Three form tool UI cards floating with connection lines to a Lovable app
    April 16, 20264 min

    Form Tools for Lovable Projects: Typeform, Tally & monday WorkForms Compared

    Part 1 of the Lovable Forms series: which SaaS form tool fits your Lovable project? Tally, Typeform & monday WorkForms c…

    Read more
    Build Forms in Lovable: React Hook Form, zod & Lovable Cloud Step by Step
    March 19, 20265 min

    Build Forms in Lovable: React Hook Form, zod & Lovable Cloud Step by Step

    Part 2 of the Lovable Forms series: how to build forms directly in Lovable – with React Hook Form, zod, shadcn/ui and Lo…

    Read more
    Glasmorphes Kontaktformular mit farbigen Eingabefeldern und Checkbox auf pastellfarbenem Hintergrund
    March 4, 20264 min

    Contact Forms in Lovable – Best Practices for Professional Forms

    Part 3 of the Lovable Forms series: production-ready best practices for contact forms in Lovable – validation, GDPR, spa…

    Read more
    Lovable Cloud vs Supabase comparison – pink cloud with heart versus green Supabase database
    March 4, 20264 min

    Lovable Cloud vs. Supabase – Why We (Almost) Always Use Supabase Directly

    Lovable Cloud is built on Supabase – but when does using Supabase directly make more sense? We explain why we almost alw…

    Read more
    Setting Up Google Login in Lovable – SSO & Auth Step by Step
    March 19, 20266 min

    Setting Up Google Login in Lovable – SSO & Auth Step by Step

    How to set up Google Sign-In for your Lovable project – from Google Cloud Console to a working login page. Complete guid…

    Read more
    The Best Lovable Resources – Your Ultimate Guide 2026
    March 18, 20263 min

    The Best Lovable Resources – Your Ultimate Guide 2026

    All essential Lovable resources in one place: official docs, community, YouTube tutorials, pricing, and our best guides …

    Read more
    Architecture diagram of a modern Vibe Coding stack with Lovable, Supabase and Resend as core components
    March 16, 20265 min

    The Vibe Coding Stack 2026: Lovable, Supabase, Resend – And What's Still Missing

    This is the tech stack we use to build full-stack apps in 2026 – without a traditional dev team. Three core tools, two f…

    Read more