A Clean, Flexible Way to Add Downloadable PDFs to Your Next.js + Sanity Blog
Here’s a simple way to think about adding PDFs to your content: instead of treating them as special attachments, you can model them as first-class content blocks. When you do that, Sanity and Portable Text handle the layout, the editing flow stays familiar for writers, and your frontend renders everything consistently. That mindset keeps the implementation clean, scalable, and aligned with how Sanity + Portable Text are meant to work.
In this guide, we’ll walk through a clear setup that stores PDFs directly in Sanity, adds cover previews, supports multiple attachments per post, and displays them seamlessly inside your Next.js components.
The goal
You want four things in your Next.js + Sanity setup:
- PDFs stored in Sanity (not Google Drive)
- Each PDF has a cover thumbnail
- Multiple PDFs per post
- Clicking the thumbnail opens/downloads the PDF
The most flexible solution is to model PDFs as a Portable Text block type. That gives you:
- unlimited PDFs per post
- the ability to place them anywhere in the body
- consistent rendering logic in one place
- no special “PDF section” needed in your schema
So instead of bolting on a separate PDFs array, we teach Portable Text a new kind of block: pdfAttachment.
Step 1: Add a PDF attachment block type to your schemas
In both schemas/post.ts and schemas/project.ts, find your body field and add this object inside the of: [] array:
{
type: "object",
name: "pdfAttachment",
title: "PDF Attachment",
fields: [
{
name: "title",
title: "Title (optional)",
type: "string",
},
{
name: "preview",
title: "PDF Cover / Preview Image",
type: "image",
options: { hotspot: true },
validation: (Rule: any) => Rule.required(),
},
{
name: "file",
title: "PDF File",
type: "file",
options: { accept: "application/pdf" },
validation: (Rule: any) => Rule.required(),
},
],
preview: {
select: {
title: "title",
media: "preview",
},
prepare({ title, media }: any) {
return {
title: title || "PDF Attachment",
media,
};
},
},
}Your body field will look roughly like this:
{
name: "body",
title: "Body",
type: "array",
of: [
{ type: "block" },
{
type: "image",
fields: [{ type: "text", name: "alt", title: "Alt" }],
},
// ✅ PDF Attachment block
{
type: "object",
name: "pdfAttachment",
title: "PDF Attachment",
fields: [
{ name: "title", title: "Title (optional)", type: "string" },
{
name: "preview",
title: "PDF Cover / Preview Image",
type: "image",
options: { hotspot: true },
validation: (Rule: any) => Rule.required(),
},
{
name: "file",
title: "PDF File",
type: "file",
options: { accept: "application/pdf" },
validation: (Rule: any) => Rule.required(),
},
],
preview: {
select: { title: "title", media: "preview" },
prepare({ title, media }: any) {
return { title: title || "PDF Attachment", media };
},
},
},
],
}Why this matters:
Portable Text blocks are inherently repeatable. Once this schema exists, your editors can insert as many PDFs as they want, in any order, without you changing your frontend again.
Step 2: Project PDF URLs in GROQ
By default, Portable Text returns asset references, not actual URLs. So you need to project the URLs into your body query.
Replace:
body,with:
body[]{
...,
_type == "pdfAttachment" => {
...,
"fileUrl": file.asset->url,
"previewUrl": preview.asset->url
}
},
Example full query:
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
slug,
publishedAt,
excerpt,
body[]{
...,
_type == "pdfAttachment" => {
...,
"fileUrl": file.asset->url,
"previewUrl": preview.asset->url
}
}
}Do the same for any project queries that return Portable Text.
Result:
Your frontend receives actual URLs to render and link with no extra fetch step.
Step 3: Render the block in PostBody.tsx
Now you teach your Portable Text renderer how to display this new block.
Add a pdfAttachment renderer inside types:
const myPortableTextComponents = {
types: {
image: ({ value }: any) => (
<Image
src={urlForImage(value).url()}
alt={value.alt || "Post image"}
width={700}
height={700}
/>
),
codeBlock: CodeBlock,
iframe: ({ value }: any) => {
if (!value?.url) return null;
return (
<div className="my-8 w-full">
<div className="aspect-video w-full overflow-hidden rounded-xl border border-zinc-800/40 bg-black/40">
<iframe
src={value.url}
title={value.title || "Embedded content"}
loading="lazy"
className="h-full w-full"
allowFullScreen
/>
</div>
{value.title && (
<p className="mt-2 text-xs text-zinc-500">{value.title}</p>
)}
</div>
);
},
// ✅ PDF block renderer
pdfAttachment: ({ value }: any) => {
const fileUrl = value?.fileUrl;
const previewUrl = value?.previewUrl;
if (!fileUrl || !previewUrl) return null;
return (
<div className="my-8">
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
download
className="group block"
>
<div className="relative overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 shadow-sm transition group-hover:shadow-md dark:border-zinc-700 dark:bg-zinc-900">
<Image
src={previewUrl}
alt={value?.title || "PDF preview"}
width={1200}
height={800}
className="h-auto w-full object-cover"
/>
{/* hover overlay */}
<div className="absolute inset-0 bg-black/0 transition group-hover:bg-black/20" />
{/* label */}
<div className="absolute bottom-3 right-3 rounded-md bg-black/70 px-3 py-1 text-xs font-medium text-white opacity-0 transition group-hover:opacity-100">
Download PDF
</div>
</div>
{value?.title && (
<p className="mt-2 text-sm font-medium text-zinc-700 dark:text-zinc-200">
{value.title}
</p>
)}
</a>
</div>
);
},
},
marks: {
code: ({ children }: any) => (
<code className="rounded-md bg-zinc-800/5 px-1.5 py-0.5 font-mono text-sm text-teal-800">
{children}
</code>
),
link: ({ children, value }: any) => {
const isExternal = value && !value.href.startsWith("/");
const rel = isExternal ? "noreferrer noopener" : undefined;
return (
<a
href={value?.href}
rel={rel}
target={isExternal ? "_blank" : undefined}
className="underline underline-offset-2 hover:text-teal-500 dark:hover:text-teal-300"
>
{children}
</a>
);
},
},
};What you get:
- cover image displayed like a “card”
- subtle hover cue
- click opens in new tab or downloads (browser-dependent)
- title optional but supported
Step 4 (optional): Add a TypeScript type
This isn’t required if you’re using any, but it’s nice for long-term clarity:
export type PdfAttachmentBlock = {
_type: "pdfAttachment";
title?: string;
fileUrl: string;
previewUrl: string;
};Later, you can union this with your other body block types.
How editors use it in Sanity Studio
Once schemas are deployed:
- Open a post or project
- Click Add block
- Choose PDF Attachment
- Upload:
- the PDF file
- the cover/preview image
- Repeat anywhere in the body
No new UI training needed. Portable Text already teaches them the pattern.
The practical takeaway
At the core, this setup works well because it respects the shape of your content system:
- Sanity stores assets and metadata
- Portable Text handles layout flexibility
- Next.js renders blocks predictably
- GROQ connects the two cleanly
So instead of building “PDF support” as a special-case feature, you’ve added one more first-class content block.
That’s the kind of change that stays maintainable a year from now — even after the project grows.

