StorageFramework IntegrationNextjs

Nextjs

Last updated:

Next.js Integration

Use Visulima Storage with Next.js for file uploads and on-demand image transformations. The createNextjsHandler function provides the easiest way to set up upload endpoints with automatic route handling.

Installation

npm install @visulima/storage next
yarn add @visulima/storage next
pnpm add @visulima/storage next

The easiest way to use Visulima Storage with Next.js is to use the createNextjsHandler function, which automatically handles all HTTP methods for each handler type:

Multipart Handler

// app/api/upload/multipart/route.ts
import { DiskStorage } from "@visulima/storage";
import { createNextjsHandler } from "@visulima/storage/handler/http/nextjs";

const storage = new DiskStorage({ directory: "./uploads" });
const handler = createNextjsHandler({ storage, type: "multipart" });

export const POST = handler;
export const GET = handler;
export const DELETE = handler;
export const OPTIONS = handler;

REST Handler

// app/api/upload/rest/route.ts
import { DiskStorage } from "@visulima/storage";
import { createNextjsHandler } from "@visulima/storage/handler/http/nextjs";

const storage = new DiskStorage({ directory: "./uploads" });
const handler = createNextjsHandler({ storage, type: "rest" });

export const POST = handler;
export const PUT = handler;
export const PATCH = handler;
export const GET = handler;
export const HEAD = handler;
export const DELETE = handler;
export const OPTIONS = handler;

TUS Handler

// app/api/upload/tus/route.ts
import { DiskStorage } from "@visulima/storage";
import { createNextjsHandler } from "@visulima/storage/handler/http/nextjs";

const storage = new DiskStorage({ directory: "./uploads" });
const handler = createNextjsHandler({ storage, type: "tus" });

export const POST = handler;
export const PATCH = handler;
export const HEAD = handler;
export const GET = handler;
export const DELETE = handler;
export const OPTIONS = handler;

With Media Transformers

You can also pass media transformers to enable image/video/audio transformations:

// app/api/upload/multipart/route.ts
import { DiskStorage } from "@visulima/storage";
import { createNextjsHandler } from "@visulima/storage/handler/http/nextjs";
import { MediaTransformer } from "@visulima/storage/transformer";
import ImageTransformer from "@visulima/storage/transformer/image";

const storage = new DiskStorage({ directory: "./uploads" });

const mediaTransformer = new MediaTransformer(storage, {
    ImageTransformer,
    maxImageSize: 10 * 1024 * 1024, // 10MB
});

const handler = createNextjsHandler({
    storage,
    mediaTransformer,
    type: "multipart",
});

export const POST = handler;
export const GET = handler;
export const DELETE = handler;
export const OPTIONS = handler;

Manual Route Registration (Advanced)

If you need more control, you can manually register routes using the fetch method:

// app/api/upload/route.ts
import { DiskStorage } from "@visulima/storage";
import { Multipart } from "@visulima/storage/handler/http/fetch";

const storage = new DiskStorage({ directory: "./uploads" });
const multipart = new Multipart({ storage });

export async function POST(request: Request) {
    try {
        return await multipart.fetch(request);
    } catch (error) {
        console.error("Upload error:", error);
        return Response.json({ error: "Upload failed" }, { status: 500 });
    }
}

Image Transformation API Route

// app/api/files/[id]/route.ts
import { LRUCache } from "lru-cache";
import { DiskStorage } from "@visulima/storage";
import ImageTransformer from "@visulima/storage/transformer/image";
import { NextRequest, NextResponse } from "next/server";

const uploadDirectory = "./uploads";

// Initialize storage
const storage = new DiskStorage({ directory: uploadDirectory });

// Initialize cache for transformed images
const cache = new LRUCache({
    max: 1000, // Maximum number of cached items
    ttl: 3600000, // 1 hour in milliseconds
});

// Initialize image transformer
const imageTransformer = new ImageTransformer(storage, {
    cache,
    maxImageSize: 10 * 1024 * 1024, // 10MB
    cacheTtl: 3600, // 1 hour
});

export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
    try {
        const { id } = params;
        const { searchParams } = new URL(request.url);

        // Get transformation parameters
        const width = searchParams.get("width");
        const height = searchParams.get("height");
        const fit = searchParams.get("fit");
        const position = searchParams.get("position");
        const quality = searchParams.get("quality");
        const lossless = searchParams.get("lossless");
        const effort = searchParams.get("effort");
        const alphaQuality = searchParams.get("alphaQuality");
        const loop = searchParams.get("loop");
        const delay = searchParams.get("delay");

        // Check if any transformation parameters are provided
        const hasTransformParams = width || height || fit || position || quality || lossless || effort || alphaQuality || loop || delay;

        if (!hasTransformParams) {
            // No transformation requested, serve original file
            try {
                const file = await storage.get({ id });

                const response = new NextResponse(file.content, {
                    status: 200,
                    headers: {
                        "Content-Type": file.contentType,
                        "Content-Length": file.size.toString(),
                        ETag: file.ETag,
                        "Cache-Control": "public, max-age=31536000", // Cache for 1 year
                        ...(file.expiredAt && { "X-Upload-Expires": file.expiredAt.toString() }),
                        ...(file.modifiedAt && { "Last-Modified": file.modifiedAt.toString() }),
                    },
                });

                return response;
            } catch (error) {
                console.error("Error serving original file:", error);
                return NextResponse.json({ error: "File not found" }, { status: 404 });
            }
        }

        // Parse transformation options
        const transformOptions: any = {};

        if (width) transformOptions.width = Number(width);
        if (height) transformOptions.height = Number(height);
        if (fit) transformOptions.fit = fit;
        if (position) transformOptions.position = position;
        if (quality) transformOptions.quality = Number(quality);
        if (lossless !== null) transformOptions.lossless = lossless === "true";
        if (effort) transformOptions.effort = Number(effort);
        if (alphaQuality) transformOptions.alphaQuality = Number(alphaQuality);
        if (loop !== null) transformOptions.loop = Number(loop);
        if (delay) transformOptions.delay = Number(delay);

        // Apply transformation
        const result = await imageTransformer.transform(id, transformOptions);

        // Return transformed image
        const response = new NextResponse(result.buffer, {
            status: 200,
            headers: {
                "Content-Type": `image/${result.format}`,
                "Content-Length": result.buffer.length.toString(),
                "Cache-Control": "public, max-age=3600", // Cache for 1 hour
                "X-Transformed": "true",
            },
        });

        return response;
    } catch (error) {
        console.error("Error transforming image:", error);
        return NextResponse.json({ error: "Image transformation failed" }, { status: 500 });
    }
}

Client-side Usage

Create a React component for uploading and displaying images:

// components/ImageUpload.tsx
"use client";

import { useState } from "react";

export default function ImageUpload() {
    const [uploadedFile, setUploadedFile] = useState<{ id: string; filename: string } | null>(null);
    const [uploading, setUploading] = useState(false);

    const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
        const file = event.target.files?.[0];
        if (!file) return;

        setUploading(true);

        try {
            const formData = new FormData();
            formData.append("file", file);

            const response = await fetch("/api/upload", {
                method: "POST",
                body: formData,
            });

            const result = await response.json();

            if (response.ok) {
                setUploadedFile(result);
            } else {
                console.error("Upload failed:", result.error);
            }
        } catch (error) {
            console.error("Upload error:", error);
        } finally {
            setUploading(false);
        }
    };

    return (
        <div>
            <input type="file" accept="image/*" onChange={handleFileUpload} disabled={uploading} />

            {uploading && <p>Uploading...</p>}

            {uploadedFile && (
                <div>
                    <h3>Uploaded: {uploadedFile.filename}</h3>

                    {/* Original image */}
                    <img src={`/api/files/${uploadedFile.id}`} alt="Original" style={{ maxWidth: "300px", margin: "10px" }} />

                    {/* Transformed images */}
                    <div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
                        <div>
                            <h4>Thumbnail (300x200)</h4>
                            <img src={`/api/files/${uploadedFile.id}?width=300&height=200&fit=cover&quality=80`} alt="Thumbnail" />
                        </div>

                        <div>
                            <h4>WebP (800px wide)</h4>
                            <img src={`/api/files/${uploadedFile.id}?width=800&format=webp&quality=85`} alt="WebP version" />
                        </div>

                        <div>
                            <h4>Grayscale</h4>
                            <img src={`/api/files/${uploadedFile.id}?width=400&height=300&grayscale=true`} alt="Grayscale" />
                        </div>
                    </div>
                </div>
            )}
        </div>
    );
}

Next.js Image Component Integration

Use Next.js Image component with transformed URLs:

// components/OptimizedImage.tsx
import Image from "next/image";

interface OptimizedImageProps {
    fileId: string;
    width: number;
    height: number;
    alt: string;
    quality?: number;
    fit?: "cover" | "contain" | "fill" | "inside" | "outside";
}

export default function OptimizedImage({ fileId, width, height, alt, quality = 80, fit = "cover" }: OptimizedImageProps) {
    const transformUrl = `/api/files/${fileId}?width=${width}&height=${height}&fit=${fit}&quality=${quality}&format=webp`;

    return (
        <Image
            src={transformUrl}
            alt={alt}
            width={width}
            height={height}
            priority={false}
            placeholder="blur"
            blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAAoDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAhEAACAQMDBQAAAAAAAAAAAAABAgMABAUGIWGRkqGx0f/EABUBAQEAAAAAAAAAAAAAAAAAAAMF/8QAGhEAAgIDAAAAAAAAAAAAAAAAAAECEgMRkf/aAAwDAQACEQMRAD8AltJagyeH0AthI5xdrLcNM91BF5pX2HaH9bcfaSXWGaRmknyJckliyjqTzSlT54b6bk+h0R+IRjWjBqO6O2mhP//Z"
        />
    );
}

Programmatic Transformations

Create server-side transformations for pre-processing:

// app/api/transform/route.ts
import { LRUCache } from "lru-cache";
import { DiskStorage } from "@visulima/storage";
import ImageTransformer from "@visulima/storage/transformer/image";
import { NextRequest, NextResponse } from "next/server";

const storage = new DiskStorage({ directory: "./uploads" });

// Initialize cache for transformed images
const cache = new LRUCache({
    max: 1000, // Maximum number of cached items
    ttl: 3600000, // 1 hour in milliseconds
});

const imageTransformer = new ImageTransformer(storage, {
    cache,
    maxImageSize: 10 * 1024 * 1024,
});

export async function POST(request: NextRequest) {
    try {
        const { fileId, transformations } = await request.json();

        const results = [];

        for (const transform of transformations) {
            const result = await imageTransformer.transform(fileId, transform);
            results.push({
                ...transform,
                size: result.buffer.length,
                format: result.format,
                url: `/api/files/${fileId}?${new URLSearchParams(transform as any).toString()}`,
            });
        }

        return NextResponse.json({ results });
    } catch (error) {
        console.error("Batch transformation error:", error);
        return NextResponse.json({ error: "Transformation failed" }, { status: 500 });
    }
}

Configuration

Add environment variables for configuration:

# .env.local
UPLOAD_DIR=./uploads
MAX_IMAGE_SIZE=10485760  # 10MB
CACHE_TTL=3600          # 1 hour
// lib/upload-config.ts
import { DiskStorage } from "@visulima/storage";
import ImageTransformer from "@visulima/storage/transformer/image";

export const storage = new DiskStorage({
    directory: process.env.UPLOAD_DIR || "./uploads",
    logger: console,
});

export const imageTransformer = new ImageTransformer(storage, {
    maxImageSize: parseInt(process.env.MAX_IMAGE_SIZE || "10485760"),
    cacheTtl: parseInt(process.env.CACHE_TTL || "3600"),
    logger: console,
});

Error Handling

Handle transformation errors gracefully:

// app/api/files/[id]/route.ts
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
    try {
        // ... transformation logic ...
    } catch (error: any) {
        console.error("Image transformation error:", error);

        // Handle specific error types
        if (error.message?.includes("not found")) {
            return NextResponse.json({ error: "File not found" }, { status: 404 });
        }

        if (error.message?.includes("exceeds maximum")) {
            return NextResponse.json({ error: "File too large" }, { status: 413 });
        }

        if (error.message?.includes("Unsupported image format")) {
            return NextResponse.json({ error: "Unsupported image format" }, { status: 415 });
        }

        return NextResponse.json({ error: "Internal server error" }, { status: 500 });
    }
}

Performance Optimization

  1. Enable Response Caching: Use Next.js caching for transformed images
  2. Optimize Bundle Size: Import only needed transformation functions
  3. Use WebP/AVIF: Serve modern formats for better compression
  4. Implement Image Preloading: Generate common sizes during upload
  5. Monitor Performance: Track transformation times and cache hit rates
Support

Contribute to our work and keep us going

Community is the heart of open source. The success of our packages wouldn't be possible without the incredible contributions of users, testers, and developers who collaborate with us every day.Want to get involved? Here are some tips on how you can make a meaningful impact on our open source projects.

Ready to help us out?

Be sure to check out the package's contribution guidelines first. They'll walk you through the process on how to properly submit an issue or pull request to our repositories.

Submit a pull request

Found something to improve? Fork the repo, make your changes, and open a PR. We review every contribution and provide feedback to help you get merged.

Good first issues

Simple issues suited for people new to open source development, and often a good place to start working on a package.
View good first issues