Clawdy
Getting started

React

Set up Clawdy file uploads in a React project

Installation

Install the required packages:

npm install @clawdyup/core @clawdyup/react

Define a File Router

Create a file router to configure which files your application accepts. This is the core of your upload configuration.

app/api/clawdy/core.ts
import { createClawdy, type FileRouter } from "@clawdyup/core";

const f = createClawdy();

export const ourFileRouter = {
  imageUploader: f({ image: { maxFileSize: "4MB" } })
    .middleware(async ({ req }) => {
      // Run server-side logic before upload, e.g. auth checks
      return { userId: "user_123" };
    })
    .onUploadComplete(async ({ metadata, file }) => {
      console.log("Upload complete for user:", metadata.userId);
      console.log("File URL:", file.url);
    }),

  bannerUploader: f({ image: { maxFileSize: "10MB" } })
    .middleware(async ({ req }) => {
      return {};
    })
    .onUploadComplete(async ({ metadata, file }) => {
      console.log("Banner uploaded:", file.url);
    }),

  fileUploader: f({ blob: { maxFileSize: "50MB" } })
    .middleware(async ({ req }) => {
      return {};
    })
    .onUploadComplete(async ({ metadata, file }) => {
      console.log("File uploaded:", file.url);
    }),
} satisfies FileRouter;

export type OurFileRouter = typeof ourFileRouter;

Create a Route Handler

Expose the file router as an API route so the client can communicate with it.

app/api/clawdy/route.ts
import { createRouteHandler } from "@clawdyup/core/server";
import { ourFileRouter } from "./core";

export const { GET, POST } = createRouteHandler({
  router: ourFileRouter,
  config: {
    apiUrl: process.env.CLAWDY_API_URL || "http://127.0.0.1:3030",
    apiKey: process.env.CLAWDY_API_KEY,
  },
});

Next.js Configuration

If you're using a local or symlinked version of @clawdyup/core (e.g. during development with file: dependencies), add transpilePackages to your next.config.mjs:

next.config.mjs
const nextConfig = {
  transpilePackages: ["@clawdyup/core", "@clawdyup/react"],
  // ...rest of your config
};

This ensures Next.js (especially Turbopack) correctly resolves subpath imports like @clawdyup/core/client and @clawdyup/core/server from symlinked packages. This is only needed when using file: dependencies — npm installs work without it.

Environment Variables

Add the following environment variables to your .env.local file:

.env.local
CLAWDY_API_URL=https://your-clawdy-server.example.com
CLAWDY_API_KEY=your_api_key_here
  • CLAWDY_API_URL -- The URL of your Clawdy server.
  • CLAWDY_API_KEY -- Your API key for authenticating with the Clawdy server.

Upload Components

Clawdy provides three ways to add uploads to your app. Generate typed components bound to your file router, then pick the approach that fits your needs.

lib/clawdy.ts
import {
  generateUploadButton,
  generateUploadDropzone,
  generateReactHelpers,
} from "@clawdyup/react";

import type { OurFileRouter } from "@/app/api/clawdy/core";

export const UploadButton = generateUploadButton<OurFileRouter>();
export const UploadDropzone = generateUploadDropzone<OurFileRouter>();
export const { useClawdy } = generateReactHelpers<OurFileRouter>();

1. UploadButton

A simple button that opens the native file picker. Best for inline upload actions.

components/image-upload.tsx
"use client";

import { useState } from "react";
import { UploadButton } from "@/lib/clawdy";

export function ImageUpload() {
  const [imageUrl, setImageUrl] = useState<string | null>(null);

  return (
    <div>
      <UploadButton
        endpoint="imageUploader"
        onClientUploadComplete={(res) => {
          // res is an array of uploaded files
          const file = res[0];
          setImageUrl(file.url);
          console.log("Uploaded:", file.name, file.url);
        }}
        onUploadError={(error) => {
          alert(`Upload failed: ${error.message}`);
        }}
        onUploadProgress={(progress) => {
          console.log(`Progress: ${progress}%`);
        }}
        onUploadBegin={(fileName) => {
          console.log("Starting upload:", fileName);
        }}
      />

      {imageUrl && (
        <img
          src={imageUrl}
          alt="Uploaded image"
          style={{ maxWidth: 400, marginTop: 16, borderRadius: 8 }}
        />
      )}
    </div>
  );
}

Props:

PropTypeDescription
endpointstringThe file router endpoint to use (type-safe)
inputobjectOptional metadata passed to middleware
onClientUploadComplete(res) => voidCalled with uploaded file info on success
onUploadError(error) => voidCalled when upload fails
onUploadProgress(progress) => voidCalled with progress percentage (0-100)
onUploadBegin(fileName) => voidCalled when upload starts
onBeforeUploadBegin(files) => File[]Transform or filter files before upload
disabledbooleanDisable the button
classNamestringCustom CSS class

2. UploadDropzone

A drag-and-drop zone for file uploads. Best for dedicated upload areas.

components/file-dropzone.tsx
"use client";

import { useState } from "react";
import { UploadDropzone } from "@/lib/clawdy";

export function FileDropzone() {
  const [uploadedFiles, setUploadedFiles] = useState<
    { name: string; url: string; size: number }[]
  >([]);
  const [progress, setProgress] = useState(0);

  return (
    <div>
      <UploadDropzone
        endpoint="fileUploader"
        onClientUploadComplete={(res) => {
          setUploadedFiles((prev) => [
            ...prev,
            ...res.map((f) => ({ name: f.name, url: f.url, size: f.size })),
          ]);
          setProgress(0);
        }}
        onUploadError={(error) => {
          alert(`Upload failed: ${error.message}`);
          setProgress(0);
        }}
        onUploadProgress={(pct) => setProgress(pct)}
      />

      {/* Progress bar */}
      {progress > 0 && progress < 100 && (
        <div style={{ marginTop: 12 }}>
          <div
            style={{
              height: 6,
              borderRadius: 3,
              backgroundColor: "#e5e7eb",
              overflow: "hidden",
            }}
          >
            <div
              style={{
                width: `${progress}%`,
                height: "100%",
                backgroundColor: "#3b82f6",
                transition: "width 0.3s",
              }}
            />
          </div>
          <p style={{ fontSize: 12, color: "#6b7280", marginTop: 4 }}>
            {progress}%
          </p>
        </div>
      )}

      {/* Display uploaded files as images */}
      {uploadedFiles.length > 0 && (
        <div
          style={{
            display: "grid",
            gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))",
            gap: 12,
            marginTop: 16,
          }}
        >
          {uploadedFiles.map((file, i) => (
            <div key={i}>
              <img
                src={file.url}
                alt={file.name}
                style={{
                  width: "100%",
                  aspectRatio: "1",
                  objectFit: "cover",
                  borderRadius: 8,
                }}
              />
              <p style={{ fontSize: 12, marginTop: 4 }}>{file.name}</p>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

The UploadDropzone accepts the same props as UploadButton. Users can click the zone or drag files onto it.


3. useClawdy Hook

Full control over the upload UI. Build your own components while Clawdy handles the upload logic.

components/custom-uploader.tsx
"use client";

import { useState, useRef } from "react";
import { useClawdy } from "@/lib/clawdy";

export function CustomUploader() {
  const [images, setImages] = useState<{ url: string; name: string }[]>([]);
  const [progress, setProgress] = useState(0);
  const inputRef = useRef<HTMLInputElement>(null);

  const { startUpload, isUploading } = useClawdy("bannerUploader", {
    onClientUploadComplete: (res) => {
      setImages((prev) => [
        ...prev,
        ...res.map((f) => ({ url: f.url, name: f.name })),
      ]);
      setProgress(0);
    },
    onUploadError: (error) => {
      console.error("Upload failed:", error.message);
      setProgress(0);
    },
    onUploadProgress: (pct) => setProgress(pct),
  });

  const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files;
    if (!files || files.length === 0) return;
    await startUpload(Array.from(files));
    e.target.value = "";
  };

  return (
    <div>
      {/* Custom upload trigger */}
      <input
        ref={inputRef}
        type="file"
        accept="image/*"
        onChange={handleFileSelect}
        style={{ display: "none" }}
        disabled={isUploading}
      />
      <button
        onClick={() => inputRef.current?.click()}
        disabled={isUploading}
        style={{
          padding: "10px 20px",
          borderRadius: 8,
          border: "none",
          backgroundColor: isUploading ? "#9ca3af" : "#ef4444",
          color: "white",
          cursor: isUploading ? "not-allowed" : "pointer",
          fontSize: 14,
          fontWeight: 600,
        }}
      >
        {isUploading ? `Uploading... ${progress}%` : "Upload Banner Image"}
      </button>

      {/* Progress bar */}
      {isUploading && (
        <div
          style={{
            marginTop: 8,
            height: 4,
            borderRadius: 2,
            backgroundColor: "#e5e7eb",
            overflow: "hidden",
          }}
        >
          <div
            style={{
              width: `${progress}%`,
              height: "100%",
              backgroundColor: "#ef4444",
              transition: "width 0.2s",
            }}
          />
        </div>
      )}

      {/* Render uploaded images */}
      {images.length > 0 && (
        <div style={{ marginTop: 16 }}>
          {images.map((img, i) => (
            <div key={i} style={{ marginBottom: 12 }}>
              <img
                src={img.url}
                alt={img.name}
                style={{
                  maxWidth: "100%",
                  borderRadius: 8,
                }}
              />
              <p style={{ fontSize: 12, color: "#6b7280", marginTop: 4 }}>
                {img.name}
              </p>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Return values from useClawdy:

ValueTypeDescription
startUpload(files, opts?) => PromiseTrigger upload with files and optional input
isUploadingbooleanWhether an upload is in progress

Options:

OptionTypeDescription
onClientUploadComplete(res) => voidSuccess callback with uploaded file info
onUploadError(error) => voidError callback
onUploadProgress(progress) => voidProgress percentage (0-100)
onUploadBegin(fileName) => voidCalled when upload starts

Upload Response

All three approaches return the same response shape on success:

interface UploadFileResponse {
  key: string;       // Unique file ID
  url: string;       // Public URL to access the file
  name: string;      // Original filename
  size: number;      // File size in bytes
  serverData: unknown; // Custom data from onUploadComplete
}

Listing & Retrieving Files

Use listFiles and getFile from @clawdyup/core/client to fetch files uploaded by the current API key.

List all files

components/file-gallery.tsx
"use client";

import { useState, useEffect } from "react";
import { listFiles } from "@clawdyup/core/client";
import type { ListedFile } from "@clawdyup/core/client";

export function FileGallery() {
  const [files, setFiles] = useState<ListedFile[]>([]);
  const [hasMore, setHasMore] = useState(false);

  useEffect(() => {
    loadFiles(0);
  }, []);

  async function loadFiles(offset: number) {
    const res = await listFiles({ limit: 20, offset });
    setFiles((prev) => (offset === 0 ? res.files : [...prev, ...res.files]));
    setHasMore(res.hasMore);
  }

  return (
    <div>
      <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))", gap: 12 }}>
        {files.map((file) => (
          <div key={file.id}>
            {file.mimeType.startsWith("image/") ? (
              <img src={file.url} alt={file.filename} style={{ width: "100%", aspectRatio: "1", objectFit: "cover", borderRadius: 8 }} />
            ) : (
              <div style={{ width: "100%", aspectRatio: "1", display: "flex", alignItems: "center", justifyContent: "center", background: "#f3f4f6", borderRadius: 8 }}>
                {file.mimeType.startsWith("video/") ? "Video" : "File"}
              </div>
            )}
            <p style={{ fontSize: 12, marginTop: 4 }}>{file.filename}</p>
          </div>
        ))}
      </div>
      {hasMore && (
        <button onClick={() => loadFiles(files.length)}>Load More</button>
      )}
    </div>
  );
}

Options for listFiles:

OptionTypeDefaultDescription
urlstring"/api/clawdy"API route URL
limitnumber50Max files per page (max 500)
offsetnumber0Pagination offset
categorystringFilter by category
tagstringFilter by tags (comma-separated)

Get a single file by ID

components/file-detail.tsx
"use client";

import { useState } from "react";
import { getFile } from "@clawdyup/core/client";
import type { FileDetail } from "@clawdyup/core/client";

export function FileViewer({ fileId }: { fileId: string }) {
  const [file, setFile] = useState<FileDetail | null>(null);

  useEffect(() => {
    getFile(fileId).then(setFile);
  }, [fileId]);

  if (!file) return <p>Loading...</p>;

  return (
    <div>
      {file.mimeType.startsWith("image/") && (
        <img src={file.url} alt={file.filename} style={{ maxWidth: "100%", borderRadius: 8 }} />
      )}
      {file.mimeType.startsWith("video/") && (
        <video src={file.url} controls style={{ maxWidth: "100%", borderRadius: 8 }} />
      )}
      <h2>{file.filename}</h2>
      <p>Size: {file.size} bytes</p>
      <p>Type: {file.mimeType}</p>
      <p>Uploaded: {new Date(file.createdAt).toLocaleDateString()}</p>
      {file.category && <p>Category: {file.category}</p>}
      {file.tags && <p>Tags: {file.tags.join(", ")}</p>}
    </div>
  );
}

FileDetail shape:

interface FileDetail {
  id: string;
  filename: string;
  url: string;
  mimeType: string;
  size: number;
  contentHash: string;
  createdAt: string;
  category?: string;
  tags?: string[];
  isChunked: boolean;
}

On this page