Keeping your Sanity media library tidy can be harder than it sounds. Over time, PDFs and DOCs get replaced, old files hang around, and you end up with orphaned assets that no one remembers to delete.
I wanted a single place to reference files across my site. If a file is updated, it updates everywhere. On top of that, I wanted a cleanup function if the old file isn't used anywhere else.
This guide shows how I built a custom upload type in Sanity with a smart publish action that does exactly that.
Why not just use a normal file field?
Out of the box, Sanity gives you a file field. It works fine for one-off uploads, but:
- No single source of truth. If the same file is linked in multiple places, updating it means editing every file.
- No cleanup. When you replace a file, the old one stays in the media library unless you remember to delete it.
How it works
At the core is a new upload document type. It stores:
title→ auto-filled from the filename.lastUpdated→ optional manual date override.file→ the actual uploaded file.
On publish, a custom action (SmartUploadAction) runs:
- Updates the document title to match the filename.
- Publishes the new file.
- Checks if the old file is orphaned.
- If it is → you’ll see a confirm dialog to delete it.
- If not → the old file stays untouched.
Let's build it!
We'll house all of this as a Sanity plugin. Create a folder plugins/uploads/.
Step 1. Define the schema
This gives you a dedicated Upload document type you can reference elsewhere.
import { FileUpIcon } from "lucide-react";
import { defineField, defineType } from "sanity";
export const uploadSchema = defineType({
name: "upload",
title: "Upload",
type: "document",
icon: FileUpIcon,
fields: [
defineField({ name: "title", type: "string", title: "Title", hidden: true }),
defineField({
name: "lastUpdated",
title: "Last Updated",
type: "date",
options: {
dateFormat: "D MMMM YYYY",
calendarTodayLabel: "Today",
},
description:
"Manually override the last updated date shown for this document.",
}),
defineField({
name: "file",
type: "file",
title: "File",
options: {
accept: ".pdf,.doc,.docx",
},
}),
],
preview: {
select: { title: "title" },
prepare({ title }) {
return { title, media: FileUpIcon };
},
},
});Step 2. Add cleanup logic
The cleanup flow lives in two parts:
fileService.ts→ fetches filenames, checks references, deletes files.useFileCleanup.ts→ manages state, dialogs, and deletion.
When you hit Publish, if the old file is only referenced by the current document, you’ll get a warning prompt like:
Would you like to delete old-report.pdf? It doesn’t seem to be in use anywhere else.
This gives you a one-click way to prevent orphaned files from building up.
import type { SanityClient } from "sanity";
// Configuration
export const FILE_SERVICE_CONFIG = {
apiVersion: "2025-02-10",
queries: {
getFile: `*[_id == $fileId][0]{ _id, originalFilename }`,
getFileReferences: `*[_id == $oldFileId][0]{
'references': *[references(^._id)]._id
}`,
},
} as const;
// Types
export interface FileInfo {
readonly id: string;
readonly name?: string;
}
export interface FileReferencesResult {
readonly references?: readonly string[];
}
// Validation
export const isValidFileId = (id: string | undefined): id is string =>
Boolean(id && typeof id === "string" && id.length > 0);
// File Service Class
export class FileService {
constructor(private client: SanityClient) {}
async getFileName(fileId: string): Promise<string | null> {
if (!isValidFileId(fileId)) {
console.warn("Invalid file ID provided to getFileName:", fileId);
return null;
}
try {
const file = await this.client.fetch(
FILE_SERVICE_CONFIG.queries.getFile,
{
fileId,
},
);
return file?.originalFilename || null;
} catch (error) {
console.error("Failed to fetch file name:", { fileId, error });
throw new Error("Failed to fetch file information");
}
}
async checkFileReferences(fileId: string): Promise<readonly string[]> {
if (!isValidFileId(fileId)) {
console.warn("Invalid file ID provided to checkFileReferences:", fileId);
return [];
}
try {
const result: FileReferencesResult = await this.client.fetch(
FILE_SERVICE_CONFIG.queries.getFileReferences,
{ oldFileId: fileId },
);
return result.references || [];
} catch (error) {
console.error("Failed to check file references:", { fileId, error });
throw new Error("Unable to determine if file can be safely deleted");
}
}
async deleteFile(fileId: string): Promise<void> {
if (!isValidFileId(fileId)) {
throw new Error(`Invalid file ID: ${fileId}`);
}
try {
await this.client.delete(fileId);
} catch (error) {
console.error("Failed to delete file:", { fileId, error });
throw new Error("Failed to delete file");
}
}
async isFileOrphaned(fileId: string, documentId: string): Promise<boolean> {
const references = await this.checkFileReferences(fileId);
return references.length === 1 && references[0] === documentId;
}
}
import { useToast } from "@sanity/ui";
import { useCallback, useState } from "react";
import type { FileService } from "../services/fileService";
import { type FileInfo } from "../services/fileService";
interface UseFileCleanupReturn {
isDeleting: boolean;
showConfirmDialog: boolean;
fileToDelete: FileInfo | null;
checkAndPromptCleanup: (
fileId: string,
fileName: string | null,
documentId: string,
) => Promise<boolean>;
confirmDelete: () => Promise<void>;
cancelDelete: () => void;
}
export function useFileCleanup(
fileService: FileService,
onComplete: () => void,
): UseFileCleanupReturn {
const [isDeleting, setIsDeleting] = useState(false);
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [fileToDelete, setFileToDelete] = useState<FileInfo | null>(null);
const toast = useToast();
const checkAndPromptCleanup = useCallback(
async (
fileId: string,
fileName: string | null,
documentId: string,
): Promise<boolean> => {
try {
const isOrphaned = await fileService.isFileOrphaned(fileId, documentId);
if (isOrphaned) {
setFileToDelete({
id: fileId,
name: fileName || "the old file",
});
setShowConfirmDialog(true);
return true; // Will show dialog
}
return false; // No cleanup needed
} catch (error) {
console.error("File cleanup check failed:", { fileId, error });
return false; // Don't block on cleanup failure
}
},
[fileService],
);
const confirmDelete = useCallback(async () => {
if (!fileToDelete || isDeleting) return;
setShowConfirmDialog(false);
setIsDeleting(true);
try {
await fileService.deleteFile(fileToDelete.id);
toast.push({
status: "success",
title: `The ${fileToDelete.name} has been deleted.`,
});
} catch (error) {
console.error("File deletion failed:", error);
toast.push({
status: "error",
title: "Failed to delete file",
description: "The file could not be deleted. Please try again.",
});
} finally {
setIsDeleting(false);
setFileToDelete(null);
onComplete();
}
}, [fileToDelete, isDeleting, fileService, toast, onComplete]);
const cancelDelete = useCallback(() => {
setShowConfirmDialog(false);
setFileToDelete(null);
onComplete();
}, [onComplete]);
return {
isDeleting,
showConfirmDialog,
fileToDelete,
checkAndPromptCleanup,
confirmDelete,
cancelDelete,
};
}Step 3. Add the smart publish action
It replaces the default Publish button with one that:
- Renames the document to the uploaded filename.
- Runs a file cleanup check.
- Prompts you before deleting old files.
import { useToast } from "@sanity/ui";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { DocumentActionProps, SanityDocument } from "sanity";
import { useClient, useDocumentOperation } from "sanity";
import { useFileCleanup } from "../hooks/useFileCleanup";
import { FILE_SERVICE_CONFIG, FileService } from "../services/fileService";
// Types
interface UploadDocument extends SanityDocument {
file?: {
asset?: {
_ref?: string;
};
};
title?: string;
}
const isValidDocument = (doc: unknown): doc is UploadDocument =>
Boolean(doc && typeof doc === "object" && doc !== null);
export function SmartUploadAction(props: DocumentActionProps) {
// Document validation
const draftDoc = useMemo(() => {
const doc = props.draft;
return isValidDocument(doc) ? (doc as UploadDocument) : null;
}, [props.draft]);
const publishedDoc = useMemo(() => {
const doc = props.published;
return isValidDocument(doc) ? (doc as UploadDocument) : null;
}, [props.published]);
// Hooks
const { patch, publish } = useDocumentOperation(props.id, props.type);
const [isPublishing, setIsPublishing] = useState(false);
const toast = useToast();
const client = useClient({ apiVersion: FILE_SERVICE_CONFIG.apiVersion });
// Services
const fileService = useMemo(() => new FileService(client), [client]);
// File cleanup logic
const fileCleanup = useFileCleanup(fileService, props.onComplete);
// File IDs
const fileIds = useMemo(
() => ({
new: draftDoc?.file?.asset?._ref,
old: publishedDoc?.file?.asset?._ref,
}),
[draftDoc?.file?.asset?._ref, publishedDoc?.file?.asset?._ref],
);
// Reset publishing state when document is published
useEffect(() => {
if (isPublishing && !props.draft) {
setIsPublishing(false);
}
}, [isPublishing, props.draft]);
// Main publish handler
const handlePublish = useCallback(async () => {
if (isPublishing) return;
setIsPublishing(true);
try {
const { new: newFileId, old: oldFileId } = fileIds;
// Get file names
const [newFileName, oldFileName] = await Promise.all([
newFileId ? fileService.getFileName(newFileId) : Promise.resolve(null),
oldFileId ? fileService.getFileName(oldFileId) : Promise.resolve(null),
]);
// Update title if we have a filename
if (newFileName?.trim()) {
patch.execute([{ set: { title: newFileName.trim() } }]);
}
// Publish the document
publish.execute();
// Handle file cleanup if needed
if (oldFileId && oldFileId !== newFileId) {
const needsCleanup = await fileCleanup.checkAndPromptCleanup(
oldFileId,
oldFileName,
props.id,
);
if (!needsCleanup) {
props.onComplete();
}
// If cleanup is needed, the dialog will handle completion
} else {
props.onComplete();
}
} catch (error) {
console.error("Publish failed:", {
error,
fileIds,
documentId: props.id,
});
toast.push({
status: "error",
title: "Publish failed",
description: "Unable to publish the document. Please try again.",
});
setIsPublishing(false);
}
}, [
isPublishing,
fileIds,
fileService,
patch,
publish,
fileCleanup,
props,
toast,
]);
// Return document action configuration
return {
disabled:
Boolean(publish.disabled) || isPublishing || fileCleanup.isDeleting,
label: isPublishing ? "Publishing…" : "Publish",
onHandle: handlePublish,
dialog: fileCleanup.showConfirmDialog && {
type: "confirm" as const,
message: `Would you like to delete ${fileCleanup.fileToDelete?.name}? It doesn't seem to be in use anywhere else. To clean up, we can delete it automatically. This action cannot be undone.`,
color: "warning" as const,
onCancel: fileCleanup.cancelDelete,
onConfirm: fileCleanup.confirmDelete,
},
};
}Step 4. Bring it all together
Inside the folder plugins/uploads/ create an index.tsx:
This registers the new schema and swaps the default Publish button with our smarter version whenever you’re editing an upload document.
import type { DocumentActionsResolver } from "sanity";
import { definePlugin } from "sanity";
import { SmartUploadAction } from "./actions/smartUploadAction";
import { uploadSchema } from "./schema/upload";
export const resolveDocumentActions: DocumentActionsResolver = (
prev,
{ schemaType },
) => {
if (["upload"].includes(schemaType)) {
prev = prev.map((originalAction) =>
originalAction.action === "publish" ? SmartUploadAction : originalAction,
);
}
return prev;
};
export const uploads = definePlugin({
name: "uploads",
document: {
actions: resolveDocumentActions,
},
schema: {
types: [uploadSchema],
},
});Finally, add it to your Studio config:
import { uploads } from "./plugins/uploads";
export default defineConfig({
// ...your config
plugins: [uploads()],
});