· 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.

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.

IPFS Ninja

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 install

We’ll also need some additional packages for file handling:

npm install @types/node

Create a .env.local file with your IPFS.NINJA API credentials:

IPFS_NINJA_API_KEY=bws_1234567890abcdef1234567890abcdef12345678
NEXT_PUBLIC_IPFS_GATEWAY=https://ipfs.ninja

You 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:

  1. Server Actions: Perfect for form submissions and server-side processing
  2. API Routes: Ideal for generating signed tokens and handling complex logic
  3. 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 hour

Putting 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:

  1. Never expose API keys client-side - Use signed tokens for client uploads
  2. Validate file types and sizes - Implement strict validation on both client and server
  3. Rate limiting - Protect your API routes from abuse
  4. Content scanning - Consider implementing virus/malware scanning for uploads
  5. CORS configuration - Properly configure CORS for your gateways

Deployment and Production Tips

For production deployment, consider:

  1. Environment variables: Use different IPFS.NINJA API keys for development and production
  2. CDN integration: IPFS.NINJA gateways work great with CloudFlare or similar CDNs
  3. Monitoring: Set up monitoring for upload failures and gateway availability
  4. 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

Back to Blog

Related Posts

View All Posts »