· Nacho Coll · Guides · 13 min read
IPFS with Next.js: Server-Side Uploads and Static Pinning
Integrate IPFS uploads into your Next.js app. Server Actions for uploads, ISR for gateway content, and API routes for signed tokens.

Next.js and IPFS make a powerful combination for building modern, decentralized applications. While traditional storage relies on centralized servers, IPFS provides content-addressable, distributed storage that’s perfect for static assets, user uploads, and immutable data.
In this comprehensive guide, we’ll build a complete Next.js application that integrates IPFS uploads using Server Actions, displays content through optimized gateways, and generates secure upload tokens for client-side operations. You’ll learn how to leverage Next.js 14’s App Router patterns with IPFS.NINJA’s pinning service for production-ready decentralized storage.

Why IPFS with Next.js?
Next.js excels at building performant web applications with server-side rendering and static generation. IPFS complements this by providing:
- Content addressing: Files are identified by their cryptographic hash, ensuring immutability
- Decentralized distribution: Content is served from multiple nodes, improving availability
- Cost efficiency: Pay for pinning storage, not bandwidth or requests
- Future-proof: No vendor lock-in with standardized IPFS protocols
Combined with IPFS.NINJA’s professional pinning service, you get enterprise-grade reliability with the benefits of decentralization.
Project Setup
Let’s start by creating a new Next.js application with the necessary dependencies:
npx create-next-app@latest ipfs-nextjs-demo --typescript --tailwind --eslint --app
cd ipfs-nextjs-demo
npm installWe’ll also need some additional packages for file handling:
npm install @types/nodeCreate a .env.local file with your IPFS.NINJA API credentials:
IPFS_NINJA_API_KEY=bws_1234567890abcdef1234567890abcdef12345678
NEXT_PUBLIC_IPFS_GATEWAY=https://ipfs.ninjaYou can get your API key from the IPFS.NINJA dashboard after creating an account.
Understanding IPFS Upload Patterns
Before diving into code, let’s understand the three main patterns for IPFS uploads in Next.js:
- Server Actions: Perfect for form submissions and server-side processing
- API Routes: Ideal for generating signed tokens and handling complex logic
- Client-side with tokens: Secure uploads directly from the browser
Each pattern serves different use cases, and we’ll implement all three to give you a complete toolkit.
Server Actions for IPFS Uploads
Server Actions are Next.js’s modern approach to handling form submissions and server-side mutations. They’re perfect for IPFS uploads because they run on the server with access to your API keys.
Create app/lib/ipfs-actions.ts:
'use server'
import { revalidatePath } from 'next/cache'
interface UploadResult {
success: boolean
cid?: string
url?: string
error?: string
}
export async function uploadToIPFS(formData: FormData): Promise<UploadResult> {
try {
const file = formData.get('file') as File
const description = formData.get('description') as string
if (!file) {
return { success: false, error: 'No file provided' }
}
// Convert file to base64 for JSON upload
const bytes = await file.arrayBuffer()
const base64 = Buffer.from(bytes).toString('base64')
const response = await fetch('https://api.ipfs.ninja/upload/new', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': process.env.IPFS_NINJA_API_KEY!,
},
body: JSON.stringify({
content: base64,
description: description || `Uploaded: ${file.name}`,
metadata: {
filename: file.name,
contentType: file.type,
size: file.size,
uploadedAt: new Date().toISOString(),
},
}),
})
if (!response.ok) {
const error = await response.text()
return { success: false, error: `Upload failed: ${error}` }
}
const result = await response.json()
// Revalidate any pages that might display uploaded files
revalidatePath('/')
return {
success: true,
cid: result.cid,
url: result.uris.url,
}
} catch (error) {
console.error('Upload error:', error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
export async function pinExistingFile(cid: string, description?: string): Promise<UploadResult> {
try {
const response = await fetch('https://api.ipfs.ninja/pin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': process.env.IPFS_NINJA_API_KEY!,
},
body: JSON.stringify({
cid,
description: description || `Pinned: ${cid}`,
}),
})
if (!response.ok) {
const error = await response.text()
return { success: false, error: `Pin failed: ${error}` }
}
const result = await response.json()
revalidatePath('/')
return {
success: true,
cid: result.cid,
url: `https://ipfs.ninja/ipfs/${result.cid}`,
}
} catch (error) {
console.error('Pin error:', error)
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}Now create the upload form component in app/components/upload-form.tsx:
'use client'
import { useState } from 'react'
import { uploadToIPFS, pinExistingFile } from '@/lib/ipfs-actions'
export function UploadForm() {
const [isUploading, setIsUploading] = useState(false)
const [result, setResult] = useState<string>('')
const [mode, setMode] = useState<'upload' | 'pin'>('upload')
async function handleSubmit(formData: FormData) {
setIsUploading(true)
setResult('')
try {
let uploadResult
if (mode === 'upload') {
uploadResult = await uploadToIPFS(formData)
} else {
const cid = formData.get('cid') as string
const description = formData.get('description') as string
uploadResult = await pinExistingFile(cid, description)
}
if (uploadResult.success) {
setResult(`✅ Success! CID: ${uploadResult.cid}\nURL: ${uploadResult.url}`)
} else {
setResult(`❌ Error: ${uploadResult.error}`)
}
} catch (error) {
setResult(`❌ Error: ${error}`)
} finally {
setIsUploading(false)
}
}
return (
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-lg">
<div className="mb-4">
<div className="flex gap-2 mb-4">
<button
type="button"
onClick={() => setMode('upload')}
className={`px-4 py-2 rounded ${
mode === 'upload'
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700'
}`}
>
Upload File
</button>
<button
type="button"
onClick={() => setMode('pin')}
className={`px-4 py-2 rounded ${
mode === 'pin'
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700'
}`}
>
Pin CID
</button>
</div>
</div>
<form action={handleSubmit} className="space-y-4">
{mode === 'upload' ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Select File
</label>
<input
type="file"
name="file"
required
className="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
) : (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
IPFS CID
</label>
<input
type="text"
name="cid"
placeholder="QmX... or baf..."
required
className="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Description (optional)
</label>
<input
type="text"
name="description"
placeholder="Describe your file..."
className="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
<button
type="submit"
disabled={isUploading}
className="w-full bg-blue-500 text-white py-2 px-4 rounded-md hover:bg-blue-600 disabled:opacity-50"
>
{isUploading ? 'Processing...' : mode === 'upload' ? 'Upload to IPFS' : 'Pin to IPFS'}
</button>
</form>
{result && (
<div className="mt-4 p-4 bg-gray-100 rounded-md">
<pre className="text-sm whitespace-pre-wrap">{result}</pre>
</div>
)}
</div>
)
}API Routes for Signed Upload Tokens
For client-side uploads, you’ll need signed tokens that provide temporary, secure access to the IPFS.NINJA API. Create app/api/upload-token/route.ts:
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
try {
const { expiresIn = 3600 } = await request.json() // Default 1 hour
const response = await fetch('https://api.ipfs.ninja/upload-tokens', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': process.env.IPFS_NINJA_API_KEY!,
},
body: JSON.stringify({
expiresIn,
description: 'Next.js client upload token',
}),
})
if (!response.ok) {
const error = await response.text()
return NextResponse.json(
{ error: 'Failed to create token' },
{ status: response.status }
)
}
const token = await response.json()
return NextResponse.json({ token: token.token })
} catch (error) {
console.error('Token generation error:', error)
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}Now create a client-side upload component in app/components/client-upload.tsx:
'use client'
import { useState } from 'react'
export function ClientUpload() {
const [isUploading, setIsUploading] = useState(false)
const [result, setResult] = useState<string>('')
async function handleUpload(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsUploading(true)
setResult('')
try {
// Get upload token
const tokenResponse = await fetch('/api/upload-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expiresIn: 3600 }),
})
if (!tokenResponse.ok) {
throw new Error('Failed to get upload token')
}
const { token } = await tokenResponse.json()
// Get file from form
const formData = new FormData(e.currentTarget)
const file = formData.get('file') as File
if (!file) {
throw new Error('No file selected')
}
// Convert to base64
const bytes = await file.arrayBuffer()
const base64 = Buffer.from(bytes).toString('base64')
// Upload to IPFS
const uploadResponse = await fetch('https://api.ipfs.ninja/upload/new', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Signed ${token}`,
},
body: JSON.stringify({
content: base64,
description: `Client upload: ${file.name}`,
metadata: {
filename: file.name,
contentType: file.type,
size: file.size,
},
}),
})
if (!uploadResponse.ok) {
const error = await uploadResponse.text()
throw new Error(`Upload failed: ${error}`)
}
const uploadResult = await uploadResponse.json()
setResult(`✅ Client upload success!\nCID: ${uploadResult.cid}\nURL: ${uploadResult.uris.url}`)
} catch (error) {
setResult(`❌ Error: ${error instanceof Error ? error.message : 'Unknown error'}`)
} finally {
setIsUploading(false)
}
}
return (
<div className="max-w-md mx-auto p-6 bg-white rounded-lg shadow-lg">
<h3 className="text-lg font-semibold mb-4">Client-side Upload</h3>
<form onSubmit={handleUpload} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Select File
</label>
<input
type="file"
name="file"
required
className="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
<button
type="submit"
disabled={isUploading}
className="w-full bg-green-500 text-white py-2 px-4 rounded-md hover:bg-green-600 disabled:opacity-50"
>
{isUploading ? 'Uploading...' : 'Upload via Client'}
</button>
</form>
{result && (
<div className="mt-4 p-4 bg-gray-100 rounded-md">
<pre className="text-sm whitespace-pre-wrap">{result}</pre>
</div>
)}
</div>
)
}Displaying IPFS Content with ISR
One of Next.js’s strengths is Incremental Static Regeneration (ISR), which works beautifully with IPFS content. Create app/gallery/[cid]/page.tsx:
import { Metadata } from 'next'
import Image from 'next/image'
import { notFound } from 'next/navigation'
interface FileMetadata {
cid: string
filename?: string
contentType?: string
size?: number
description?: string
}
async function getFileInfo(cid: string): Promise<FileMetadata | null> {
try {
// In a real app, you'd fetch this from your database or IPFS.NINJA API
const response = await fetch(`https://api.ipfs.ninja/files/${cid}`, {
headers: {
'X-Api-Key': process.env.IPFS_NINJA_API_KEY!,
},
next: {
revalidate: 3600, // Revalidate every hour
},
})
if (!response.ok) {
return null
}
return await response.json()
} catch (error) {
console.error('Failed to fetch file info:', error)
return null
}
}
export async function generateMetadata({ params }: { params: { cid: string } }): Promise<Metadata> {
const fileInfo = await getFileInfo(params.cid)
if (!fileInfo) {
return {
title: 'File Not Found',
}
}
return {
title: fileInfo.filename || `IPFS File ${params.cid}`,
description: fileInfo.description || `View file on IPFS: ${params.cid}`,
openGraph: {
title: fileInfo.filename || `IPFS File`,
description: fileInfo.description || 'Decentralized file storage',
images: fileInfo.contentType?.startsWith('image/')
? [`https://ipfs.ninja/ipfs/${params.cid}`]
: undefined,
},
}
}
export default async function FilePage({ params }: { params: { cid: string } }) {
const fileInfo = await getFileInfo(params.cid)
if (!fileInfo) {
notFound()
}
const isImage = fileInfo.contentType?.startsWith('image/')
const isVideo = fileInfo.contentType?.startsWith('video/')
const isAudio = fileInfo.contentType?.startsWith('audio/')
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
<div className="p-6">
<h1 className="text-2xl font-bold mb-4">
{fileInfo.filename || `File ${params.cid}`}
</h1>
<div className="grid grid-cols-2 gap-4 mb-6">
<div>
<span className="font-semibold">CID:</span>
<code className="ml-2 text-sm bg-gray-100 px-2 py-1 rounded">
{params.cid}
</code>
</div>
<div>
<span className="font-semibold">Type:</span>
<span className="ml-2">{fileInfo.contentType || 'Unknown'}</span>
</div>
{fileInfo.size && (
<div>
<span className="font-semibold">Size:</span>
<span className="ml-2">{(fileInfo.size / 1024).toFixed(1)} KB</span>
</div>
)}
</div>
{fileInfo.description && (
<p className="text-gray-600 mb-6">{fileInfo.description}</p>
)}
</div>
<div className="border-t">
{isImage && (
<div className="p-6">
<Image
src={`https://ipfs.ninja/ipfs/${params.cid}`}
alt={fileInfo.filename || 'IPFS Image'}
width={800}
height={600}
className="max-w-full h-auto rounded-lg"
unoptimized // IPFS content doesn't need Next.js optimization
/>
</div>
)}
{isVideo && (
<div className="p-6">
<video
controls
className="w-full max-w-2xl mx-auto rounded-lg"
preload="metadata"
>
<source src={`https://ipfs.ninja/ipfs/${params.cid}`} />
Your browser does not support the video tag.
</video>
</div>
)}
{isAudio && (
<div className="p-6">
<audio
controls
className="w-full max-w-lg mx-auto"
preload="metadata"
>
<source src={`https://ipfs.ninja/ipfs/${params.cid}`} />
Your browser does not support the audio tag.
</audio>
</div>
)}
<div className="p-6 bg-gray-50">
<div className="flex gap-4">
<a
href={`https://ipfs.ninja/ipfs/${params.cid}`}
target="_blank"
rel="noopener noreferrer"
className="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600"
>
View on IPFS Gateway
</a>
<a
href={`https://ipfs.ninja/ipfs/${params.cid}?download=true`}
className="bg-gray-500 text-white px-4 py-2 rounded-md hover:bg-gray-600"
>
Download
</a>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
export const revalidate = 3600 // Revalidate every hourPutting It All Together
Update your main page in app/page.tsx to showcase all the upload methods:
import { UploadForm } from '@/components/upload-form'
import { ClientUpload } from '@/components/client-upload'
export default function Home() {
return (
<div className="min-h-screen bg-gray-100 py-12">
<div className="container mx-auto px-4">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
IPFS with Next.js Demo
</h1>
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
Upload files to IPFS using Server Actions, API routes, and client-side tokens.
All powered by IPFS.NINJA pinning service.
</p>
</div>
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
<div>
<h2 className="text-2xl font-semibold mb-4 text-center">Server Actions</h2>
<UploadForm />
</div>
<div>
<h2 className="text-2xl font-semibold mb-4 text-center">Client Upload</h2>
<ClientUpload />
</div>
</div>
<div className="mt-16 max-w-4xl mx-auto">
<h2 className="text-2xl font-semibold mb-6 text-center">Why This Approach Works</h2>
<div className="grid md:grid-cols-3 gap-6">
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold mb-3">Server Actions</h3>
<p className="text-gray-600">
Perfect for traditional form uploads. Server-side processing with direct API access.
Great for SEO and progressive enhancement.
</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold mb-3">Signed Tokens</h3>
<p className="text-gray-600">
Secure client-side uploads without exposing API keys. Time-limited tokens
provide just enough access for uploads.
</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold mb-3">ISR + Gateways</h3>
<p className="text-gray-600">
Fast content delivery with incremental regeneration. IPFS gateways
provide global CDN-like performance.
</p>
</div>
</div>
</div>
</div>
</div>
)
}Advanced Features and Optimizations
Image Optimization with IPFS.NINJA
IPFS.NINJA provides image optimization endpoints that work perfectly with Next.js. Create app/components/optimized-image.tsx:
'use client'
import { useState } from 'react'
interface OptimizedImageProps {
cid: string
alt: string
width?: number
height?: number
quality?: number
className?: string
}
export function OptimizedImage({
cid,
alt,
width = 800,
height = 600,
quality = 80,
className
}: OptimizedImageProps) {
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(false)
const optimizedUrl = `https://api.ipfs.ninja/image/${cid}?width=${width}&height=${height}&quality=${quality}`
const fallbackUrl = `https://ipfs.ninja/ipfs/${cid}`
return (
<div className={`relative ${className}`}>
{isLoading && (
<div className="absolute inset-0 bg-gray-200 animate-pulse rounded" />
)}
<img
src={error ? fallbackUrl : optimizedUrl}
alt={alt}
onLoad={() => setIsLoading(false)}
onError={() => {
setError(true)
setIsLoading(false)
}}
className={`${isLoading ? 'invisible' : 'visible'} max-w-full h-auto rounded-lg`}
/>
</div>
)
}Error Handling and Retry Logic
Implement robust error handling for production use:
// app/lib/ipfs-client.ts
class IPFSClient {
private apiKey: string
private baseUrl: string
private maxRetries: number
constructor(apiKey: string, baseUrl = 'https://api.ipfs.ninja', maxRetries = 3) {
this.apiKey = apiKey
this.baseUrl = baseUrl
this.maxRetries = maxRetries
}
async uploadWithRetry(content: string, metadata: any, attempt = 1): Promise<any> {
try {
const response = await fetch(`${this.baseUrl}/upload/new`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': this.apiKey,
},
body: JSON.stringify({ content, ...metadata }),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`)
}
return await response.json()
} catch (error) {
if (attempt < this.maxRetries) {
// Exponential backoff
const delay = Math.pow(2, attempt) * 1000
await new Promise(resolve => setTimeout(resolve, delay))
return this.uploadWithRetry(content, metadata, attempt + 1)
}
throw error
}
}
}
export const ipfsClient = new IPFSClient(process.env.IPFS_NINJA_API_KEY!)Performance Best Practices
When working with IPFS and Next.js, consider these optimization strategies:
1. Lazy Loading Content
Use Next.js dynamic imports to lazy load heavy IPFS content:
import dynamic from 'next/dynamic'
const IPFSViewer = dynamic(() => import('@/components/ipfs-viewer'), {
loading: () => <div>Loading IPFS content...</div>,
ssr: false, // Skip SSR for client-heavy components
})2. Caching Strategies
Implement smart caching for IPFS metadata:
// Use Next.js cache for file metadata
const cachedFileInfo = cache(async (cid: string) => {
const response = await fetch(`https://api.ipfs.ninja/files/${cid}`, {
headers: { 'X-Api-Key': process.env.IPFS_NINJA_API_KEY! },
next: { revalidate: 86400 }, // Cache for 24 hours
})
return response.json()
})3. Progressive Enhancement
Ensure your app works without JavaScript by using Server Actions as the foundation and adding client-side enhancements:
export function ProgressiveUploadForm() {
return (
<form action={uploadToIPFS}>
{/* Works without JS */}
<input type="file" name="file" required />
<button type="submit">Upload</button>
{/* Enhanced with JS */}
<ProgressIndicator />
</form>
)
}Security Considerations
When building production IPFS applications, keep these security practices in mind:
- Never expose API keys client-side - Use signed tokens for client uploads
- Validate file types and sizes - Implement strict validation on both client and server
- Rate limiting - Protect your API routes from abuse
- Content scanning - Consider implementing virus/malware scanning for uploads
- CORS configuration - Properly configure CORS for your gateways
Deployment and Production Tips
For production deployment, consider:
- Environment variables: Use different IPFS.NINJA API keys for development and production
- CDN integration: IPFS.NINJA gateways work great with CloudFlare or similar CDNs
- Monitoring: Set up monitoring for upload failures and gateway availability
- Backup strategy: While IPFS provides redundancy, consider backup pinning services
The combination of Next.js and IPFS opens up exciting possibilities for building truly decentralized applications. Whether you’re creating an NFT marketplace, a decentralized blog, or a file sharing platform, this architecture provides the performance, security, and user experience your users expect.
For more details on IPFS concepts, check out our guides on what IPFS pinning is and how to upload files to IPFS. If