---
name: squad-ui-file-upload
description: Set up S3 file upload with presigned URLs for a Next.js app using @thesqd/squad-ui FileUpload component
---

# Squad UI File Upload Setup Skill

Set up S3 presigned URL file uploads in a Next.js App Router project using the Squad UI FileUpload component.

## Pre-flight Checks

1. Confirm this is a Next.js project (`"next"` in `package.json`)
2. Confirm App Router (`src/app/` or `app/` directory exists)
3. Determine app directory path (`src/app` or `app`) — call it `{APP}`
4. Check if `@thesqd/squad-ui` is installed. If not, install: `npm install github:sis-thesqd/squad-ui`

## Step 1: Install S3 Dependencies

```bash
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
```

## Step 2: Get S3 Credentials

Ask the user for their AWS S3 bucket details. If they don't have one, suggest they create one or use the default company bucket.

Required values:
- `AWS_ACCESS_KEY_ID` — IAM access key
- `AWS_SECRET_ACCESS_KEY` — IAM secret key
- `AWS_REGION` — e.g., `us-east-1`
- `S3_BUCKET_NAME` — bucket name
- `S3_PUBLIC_URL` — (optional) CloudFront or public bucket URL prefix

## Step 3: Add Environment Variables

Add to `.env.local` (merge with existing):

```env
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_REGION=us-east-1
S3_BUCKET_NAME=your-bucket-name
S3_PUBLIC_URL=https://your-bucket.s3.amazonaws.com
```

## Step 4: Create S3 Client Utility

Create `{APP}/lib/s3.ts`:

```ts
import { S3Client } from "@aws-sdk/client-s3";

export const s3 = new S3Client({
  region: process.env.AWS_REGION!,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
  },
});

export const BUCKET = process.env.S3_BUCKET_NAME!;
export const PUBLIC_URL = process.env.S3_PUBLIC_URL || `https://${BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com`;
```

## Step 5: Create Presign API Route

Create `{APP}/api/upload/presign/route.ts`:

```ts
import { NextResponse } from "next/server";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { s3, BUCKET } from "@/app/lib/s3";

export async function POST(request: Request) {
  try {
    const { filename, contentType, size } = await request.json();

    if (!filename || !contentType) {
      return NextResponse.json({ error: "Missing filename or contentType" }, { status: 400 });
    }

    // Max 50MB
    if (size && size > 50 * 1024 * 1024) {
      return NextResponse.json({ error: "File too large (max 50MB)" }, { status: 400 });
    }

    const key = `uploads/${Date.now()}-${filename.replace(/[^a-zA-Z0-9._-]/g, "_")}`;

    const command = new PutObjectCommand({
      Bucket: BUCKET,
      Key: key,
      ContentType: contentType,
    });

    const url = await getSignedUrl(s3, command, { expiresIn: 300 }); // 5 min

    return NextResponse.json({ url, key });
  } catch (error: any) {
    console.error("Presign error:", error);
    return NextResponse.json({ error: "Failed to generate upload URL" }, { status: 500 });
  }
}
```

## Step 6: Create Complete API Route

Create `{APP}/api/upload/complete/route.ts`:

```ts
import { NextResponse } from "next/server";
import { PUBLIC_URL } from "@/app/lib/s3";

export async function POST(request: Request) {
  try {
    const { key, filename } = await request.json();

    if (!key) {
      return NextResponse.json({ error: "Missing key" }, { status: 400 });
    }

    const url = `${PUBLIC_URL}/${key}`;

    return NextResponse.json({ url, key, filename });
  } catch (error: any) {
    console.error("Complete error:", error);
    return NextResponse.json({ error: "Failed to complete upload" }, { status: 500 });
  }
}
```

## Step 7: Create Delete API Route

Create `{APP}/api/upload/delete/route.ts`:

```ts
import { NextResponse } from "next/server";
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
import { s3, BUCKET } from "@/app/lib/s3";

export async function POST(request: Request) {
  try {
    const { key } = await request.json();

    if (!key) {
      return NextResponse.json({ error: "Missing key" }, { status: 400 });
    }

    await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key }));

    return NextResponse.json({ success: true });
  } catch (error: any) {
    console.error("Delete error:", error);
    return NextResponse.json({ error: "Failed to delete file" }, { status: 500 });
  }
}
```

## Step 8: Add Auth Protection (Optional but Recommended)

If the auth skill has been set up, protect the upload routes. Add this check at the top of each route handler:

```ts
import { createClient } from "@supabase/supabase-js";

// Add inside the POST handler, before any logic:
const authHeader = request.headers.get("authorization");
if (!authHeader) {
  return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
```

Or use the Supabase client to verify the JWT token if auth is configured.

## Step 9: Usage Example

Create a demo page or add to an existing page:

```tsx
"use client";

import { FileUpload } from "@thesqd/squad-ui";

export default function UploadPage() {
  return (
    <div style={{ maxWidth: 500, margin: "0 auto", padding: "2rem" }}>
      <FileUpload
        mode="endpoint"
        endpoint="/api/upload"
        label="Upload Files"
        description="Drag & drop or click to upload"
        acceptedFileTypes={["image/*", "application/pdf"]}
        maxFileSize="10MB"
        maxFiles={5}
        allowImagePreview
        onUpload={(files) => {
          console.log("Uploaded:", files);
        }}
      />
    </div>
  );
}
```

## Step 10: Supabase Storage Alternative

If the user prefers Supabase Storage over S3 (simpler, no extra env vars):

```tsx
"use client";

import { FileUpload, useAuth } from "@thesqd/squad-ui";

export default function UploadPage() {
  const { supabase } = useAuth();

  return (
    <FileUpload
      mode="supabase"
      bucket="uploads"
      path="user-files"
      supabaseClient={supabase}
      label="Upload Files"
      maxFileSize="10MB"
      onUpload={(files) => console.log(files)}
    />
  );
}
```

For this to work, create a Supabase Storage bucket named "uploads" in the Supabase dashboard.

## Step 11: Verify

Run `npx next build` to verify compilation.

## Step 12: Tell the User

> ✅ File upload setup complete!
>
> **API routes created:**
> - `POST /api/upload/presign` — generates presigned S3 PUT URL
> - `POST /api/upload/complete` — confirms upload, returns public URL
> - `POST /api/upload/delete` — removes file from S3
>
> **Component usage:**
> ```tsx
> import { FileUpload } from "@thesqd/squad-ui";
> <FileUpload mode="endpoint" endpoint="/api/upload" />
> ```
>
> **Props available:**
> - `acceptedFileTypes` — e.g., `["image/*", ".pdf"]`
> - `maxFileSize` — e.g., `"10MB"`
> - `maxFiles` — max number of files
> - `imageValidateSizeMaxWidth/MaxHeight` — max image dimensions
> - `fileRenameFunction` — custom rename logic
> - `fileMetadataObject` — attach metadata to uploads
> - `onUpload` — callback with uploaded file URLs

## Troubleshooting

### "Failed to generate upload URL"
**Cause:** AWS credentials are wrong or bucket doesn't exist.
**Fix:** Verify `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION`, and `S3_BUCKET_NAME` in `.env.local`.

### "CORS error on S3 upload"
**Cause:** S3 bucket CORS policy blocks browser uploads.
**Fix:** Add this CORS configuration to the S3 bucket:
```json
[
  {
    "AllowedHeaders": ["*"],
    "AllowedMethods": ["PUT", "GET"],
    "AllowedOrigins": ["http://localhost:*", "https://your-domain.com"],
    "ExposeHeaders": ["ETag"]
  }
]
```

### "File too large" but within maxFileSize
**Cause:** The presign route has a 50MB hard limit.
**Fix:** Adjust the size check in `/api/upload/presign/route.ts`.

### FilePond CSS not loading
**Cause:** The FilePond CSS is bundled with squad-ui. Make sure `@thesqd/squad-ui/styles.css` is imported.
**Fix:** Add `@import "@thesqd/squad-ui/styles.css";` before `@import "tailwindcss";` in `globals.css`.
