
How to Manage Files and Assets in Sanity
To manage files and assets in Sanity, add fields with type: 'file' for documents such as PDFs, audio and archives, and type: 'image' for pictures. Every upload becomes an asset document, sanity.fileAsset or sanity.imageAsset, stored in your dataset and served from Sanity's CDN, while your content documents hold a reference to it. You query assets with GROQ like any other document, create download links by adding ?dl= to the asset URL, upload in bulk with client.assets.upload() and delete an asset by deleting its document once nothing references it.
Assets are easy to upload and easy to forget. Over time a project collects duplicate brochures, outdated price lists and images nobody uses, and because dataset assets are public by URL, a forgotten file can still be reachable years later. This guide covers the full lifecycle: defining file fields, what an asset document contains, querying and presenting files, uploading from scripts, finding and cleaning up unused assets, and the privacy and storage details you need to know.
Files vs Images
Sanity has two asset types, and choosing the right one matters:
| Feature | image | file |
|---|---|---|
| Asset document type | sanity.imageAsset | sanity.fileAsset |
| URL path | cdn.sanity.io/images/... | cdn.sanity.io/files/... |
| On-demand resizing and formats | Yes, through the image pipeline | No, served as uploaded |
| Metadata | Dimensions, LQIP, palette, optional EXIF | Size, MIME type, extension |
| Crop and hotspot | Yes | No |
| Typical uses | Photos, illustrations, logos | PDFs, audio, ZIPs, spreadsheets, short video |
Sanity's docs are clear that you shouldn't use file for images. An image uploaded as a file has no image metadata and can't be resized or cropped by the pipeline. The image side of things is covered in how to handle images in Sanity. This post focuses on files and on assets in general.
Add a File Field
A file field works like an object with a hidden asset reference. Add your own fields for context such as a title or description:
// schemaTypes/resource.ts
import { defineField, defineType } from "sanity";
import { DocumentPdfIcon } from "@sanity/icons";
export const resource = defineType({
name: "resource",
title: "Resource",
type: "document",
icon: DocumentPdfIcon,
fields: [
defineField({
name: "title",
type: "string",
validation: (rule) => rule.required(),
}),
defineField({
name: "document",
title: "PDF",
type: "file",
options: {
accept: "application/pdf",
storeOriginalFilename: false,
},
fields: [
defineField({
name: "description",
type: "string",
description: "Shown under the download link.",
}),
],
validation: (rule) => rule.required().assetRequired(),
}),
],
});
A few options do most of the work:
accept: Restricts uploads by MIME type, like the HTMLacceptattribute. Sanity recommends MIME types such asapplication/pdfover extensions such as.pdf, because browsers don't expose file names during drag and drop.storeOriginalFilename: Defaults totrue. Set it tofalseif file names might reveal something sensitive, such asq3-layoffs-draft.pdf.disableNew: Hides the upload button so editors can only pick existing assets, which suits teams with a central asset library.assetRequired(): Used withrequired(), it ensures an actual file is attached, not just an empty object with a description.
You can accept several types in one string:
options: {
accept: 'application/pdf, application/vnd.openxmlformats-officedocument.wordprocessingml.document',
}
What an Asset Document Contains
When a file is uploaded, Sanity creates an asset document. You can see one by querying for it:
*[_type == "sanity.fileAsset"][0]
{
"_id": "file-3a9f1c2b7e5d4a6c8b0e2f4d6a8c1e3b5d7f9a0c-pdf",
"_type": "sanity.fileAsset",
"assetId": "3a9f1c2b7e5d4a6c8b0e2f4d6a8c1e3b5d7f9a0c",
"extension": "pdf",
"mimeType": "application/pdf",
"originalFilename": "annual-report-2026.pdf",
"path": "files/abc123/production/3a9f1c2b7e5d4a6c8b0e2f4d6a8c1e3b5d7f9a0c.pdf",
"sha1hash": "3a9f1c2b7e5d4a6c8b0e2f4d6a8c1e3b5d7f9a0c",
"size": 2483915,
"url": "https://cdn.sanity.io/files/abc123/production/3a9f1c2b7e5d4a6c8b0e2f4d6a8c1e3b5d7f9a0c.pdf"
}
Three behaviours follow from this structure:
- Assets are deduplicated by content: The ID is based on a hash of the file. Upload the same PDF twice under different names and you get one asset.
- Edits create new assets: Change one byte and the hash changes, so you get a new asset with a new URL. The old one stays until you delete it.
- URLs never go stale: Because the URL includes the content hash, the CDN can cache assets indefinitely without serving outdated versions.
Asset documents also have optional title, description, altText and creditLine fields. Studio doesn't edit these by default, but tools such as the Media plugin do, which is covered in using Sanity media library plugins.
Query Files With GROQ
Your content document only stores a reference, so dereference it to get the URL and details:
*[_type == "resource"] | order(title asc){
_id,
title,
"description": document.description,
"file": document.asset->{
url,
originalFilename,
extension,
mimeType,
size
}
}
If the -> operator is new to you, read how to dereference documents in GROQ.
Build Download Links
By default, a file URL opens in the browser where possible, so a PDF displays in the browser's viewer. To force a download, add the dl parameter:
https://cdn.sanity.io/files/abc123/production/3a9f...a0c.pdf?dl=
https://cdn.sanity.io/files/abc123/production/3a9f...a0c.pdf?dl=annual-report.pdf
An empty dl= uses the original filename if it was stored, or the asset ID otherwise. A value after dl= sets the downloaded file's name.
Here's a reusable React server component that renders a download link with the file type and size:
// src/components/file-download.tsx
type FileAsset = {
url: string;
originalFilename?: string;
extension: string;
size: number;
};
type FileDownloadProps = {
title: string;
description?: string;
file: FileAsset;
};
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function FileDownload({ title, description, file }: FileDownloadProps) {
const filename = file.originalFilename ?? `${title}.${file.extension}`;
const href = `${file.url}?dl=${encodeURIComponent(filename)}`;
return (
<a
href={href}
className="flex items-center justify-between rounded-lg border p-4 hover:bg-slate-50"
>
<span>
<span className="block font-medium">{title}</span>
{description && (
<span className="block text-sm text-slate-600">{description}</span>
)}
</span>
<span className="text-sm uppercase text-slate-500">
{file.extension} · {formatBytes(file.size)}
</span>
</a>
);
}
Showing the format and size is a small courtesy that readers appreciate, especially on mobile data.
Build URLs Without Dereferencing
If you only have the reference, @sanity/asset-utils can build the URL from the asset ID and your project details:
import { buildFileUrl, parseFileAssetId } from "@sanity/asset-utils";
const ref = resource.document.asset._ref; // "file-3a9f...a0c-pdf"
const { assetId, extension } = parseFileAssetId(ref);
const url = buildFileUrl(
{ assetId, extension },
{ projectId: "abc123", dataset: "production" },
);
This saves a dereference in large queries, at the cost of not knowing the file size or original name.
Files in Portable Text
To let editors attach files inside rich text, add a file member to the block array:
import { defineArrayMember, defineField, defineType } from "sanity";
export const blockContent = defineType({
name: "blockContent",
type: "array",
of: [
defineArrayMember({ type: "block" }),
defineArrayMember({
name: "attachment",
type: "file",
fields: [defineField({ name: "label", type: "string" })],
}),
],
});
Expand the asset in your query so the renderer has a URL:
body[]{
...,
_type == "attachment" => {
...,
asset->{ url, extension, size, originalFilename }
}
}
Then add an attachment entry to your @portabletext/react types components and reuse FileDownload. The same pattern applies to any custom block, as shown in adding custom blocks to Portable Text.
Upload Assets From Scripts
For migrations or bulk imports, upload with @sanity/client and a token with write access. Run this in Node.js, never in the browser:
// scripts/upload-pdfs.ts
import { createClient } from "@sanity/client";
import { createReadStream, readdirSync } from "node:fs";
import { join } from "node:path";
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: "production",
apiVersion: "2026-03-01",
useCdn: false,
token: process.env.SANITY_API_TOKEN,
});
const dir = "./pdfs";
for (const name of readdirSync(dir).filter((f) => f.endsWith(".pdf"))) {
const asset = await client.assets.upload(
"file",
createReadStream(join(dir, name)),
{
filename: name,
contentType: "application/pdf",
},
);
await client.create({
_type: "resource",
title: name.replace(/\.pdf$/, "").replace(/-/g, " "),
document: {
_type: "file",
asset: { _type: "reference", _ref: asset._id },
},
});
console.log(`Uploaded ${name} as ${asset._id}`);
}
Sanity rate-limits asset uploads per IP, currently 25 requests per second, so this sequential loop is safe. If you parallelise a large import, throttle it. To import from a public URL instead of a local file, use the Assets HTTP API's from-url endpoints, since the JavaScript client's assets.upload doesn't support URLs.
Find Unused Assets
Unused assets build up when editors replace files or delete documents. GROQ's references() function finds them:
*[_type in ["sanity.fileAsset", "sanity.imageAsset"] && count(*[references(^._id)]) == 0]{
_id,
originalFilename,
size,
_createdAt
} | order(size desc)
The inner query counts documents that reference each asset, and ^._id refers to the asset in the outer query. Sorting by size shows the biggest wins first. Run it in Vision before deleting anything, and be aware that this query can be slow on large datasets.
Pay attention to the perspective. With recent API versions, queries default to the published perspective, which hides drafts and release versions. An asset used only in an unpublished draft would then look unused. Switch Vision's perspective to raw, or pass perspective: 'raw' in the client, so drafts and versions count as references.
Delete Assets Safely
To delete an asset, delete its asset document:
await client.delete("file-3a9f1c2b7e5d4a6c8b0e2f4d6a8c1e3b5d7f9a0c-pdf");
Content Lake rejects the delete if any document still holds a strong reference to the asset, and the error names the referencing documents. Remove those references first, or make them weak if you genuinely want the link to break.
For a bulk clean-up, combine the unused-assets query with a transaction:
// scripts/delete-unused-assets.ts
const unused = await client.fetch<string[]>(
`*[_type == "sanity.fileAsset" && count(*[references(^._id)]) == 0]._id`,
{},
{ perspective: "raw" },
);
if (unused.length === 0) {
console.log("No unused file assets.");
} else {
const tx = client.transaction();
unused.forEach((id) => tx.delete(id));
await tx.commit();
console.log(`Deleted ${unused.length} unused file assets.`);
}
Export a backup first with npx sanity dataset export production backup.tar.gz, which includes asset files, so you can re-import anything deleted by mistake. Also remember that the CDN may keep serving a cached copy for a while after deletion.
Privacy and Access
This is the most important thing to understand about Sanity assets: assets stored in a dataset are public, even when the dataset itself is private. A private dataset hides asset URLs from unauthenticated API queries, and the URLs contain a content hash so they're impractical to guess, but anyone who has a URL can download the file.
That means:
- Don't upload confidential files such as contracts, invoices or personal data to a public site's dataset.
- Treat shared links as shared files: if a URL is pasted into an email or chat, assume it's public.
- Use the right tool for private files: Sanity's docs say per-asset visibility and signed URLs are available only for Media Library assets. Otherwise, host sensitive files elsewhere and store a link in Sanity.
Video and Large Files
Content Lake treats video as an ordinary file. That works for a short clip, but there's no streaming, adaptive bitrate or poster generation. For real video, use the official Mux integration or another plugin from the Sanity Exchange, or Media Library's video support if your organisation has it.
Organising Assets at Scale
Studio's default asset browser is basic: it lets you pick previously uploaded assets, but not tag, search deeply or bulk-delete them. For larger projects, two options help:
- The Media plugin: Adds a full asset browser to Studio with tags, folders, search and bulk actions.
- Media Library: Sanity's organisation-wide asset management app, available as an add-on for certain Enterprise plans.
Both are compared in using Sanity media library plugins.
FAQ: Files and Assets in Sanity
The file type accepts any file, including PDFs, Office documents, audio, video and archives. Use the accept option to restrict a field to specific MIME types, such as application/pdf.
Append ?dl= to the asset URL. An empty value uses the original filename if it was stored, and a value such as ?dl=report.pdf sets the downloaded filename.
No. Dataset assets are public on Sanity's asset CDN even if the dataset is private. The URLs are hard to guess, but anyone with a URL can fetch the file. Per-asset privacy and signed URLs are Media Library features.
Content Lake blocks deletion while any document holds a strong reference to the asset. The error message lists the referencing documents. Remove or replace those references, including in drafts, then delete the asset.
No. Sanity identifies assets by a hash of their contents, so identical files become a single asset regardless of filename. Changing the file's contents creates a new asset.
Query asset documents where count(*[references(^._id)]) == 0, using the raw perspective so drafts are included. Run it in Vision to review the results, back up your dataset, then delete the asset documents with a transaction.
Conclusion
Files and assets in Sanity follow the same model as everything else: they're documents in your dataset, connected to content by references. Use file fields with accept and assetRequired() for downloads, dereference asset-> in GROQ for URLs and details, and add ?dl= when you want a download rather than a preview.
The ongoing work is housekeeping. Upload in bulk with the client and a server-side token, find unused assets with references(), delete them in transactions after a backup, and never treat dataset assets as private. As your library grows, a dedicated asset tool such as the Media plugin will make that housekeeping far easier for your editors.


