Skip to main content

Next.js ImageEditor Integration Guide

Complete guide for integrating the @distralabs/media-editor ImageEditor component into your Next.js application.

Table of Contents


Prerequisites

  • Node.js 16.x or higher
  • Next.js 14.x or 15.x
  • React 18.2.0 (SDK requires React 18, not React 19)

Installation

1. Install the SDK

npm install @distralabs/media-editor

2. Install Required Dependencies

npm install framer-motion lucide-react clsx tailwind-merge

3. Configure Next.js for React 18

If you’re using Next.js 15+ (which requires React 19), you need to downgrade to Next.js 14: package.json:
{
  "dependencies": {
    "@distralabs/media-editor": "^1.1.9",
    "next": "14.2.15",
    "react": "18.2.0",
    "react-dom": "18.2.0"
  },
  "devDependencies": {
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "eslint": "^8",
    "eslint-config-next": "14.2.15"
  },
  "overrides": {
    "react": "18.2.0",
    "react-dom": "18.2.0"
  }
}

Basic Setup

1. Import SDK Styles

Add the SDK CSS to your global styles file: app/globals.css:
@import "tailwindcss";

/* Import media-editor SDK styles */
@import "@distralabs/media-editor/dist/index.css";

/* Your other styles */

2. Configure Next.js

next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config) => {
    // Disable node modules that don't work in browser
    config.resolve.alias = {
      ...config.resolve.alias,
      canvas: false,
      fs: false,
    };
    return config;
  },
};

module.exports = nextConfig;

3. Use Dynamic Import (Avoid SSR)

The SDK uses browser-only APIs, so you must use dynamic imports:
'use client';

import dynamic from 'next/dynamic';

const ImageEditor = dynamic(
  () => import('@distralabs/media-editor').then(mod => ({ default: mod.ImageEditor })),
  { ssr: false }
);

ImageEditor Interface

Core Props

interface ImageEditorProps {
  // Required
  licenseKey: string;              // Your SDK license key (JWT)
  onClose: () => void;             // Called when user closes editor

  // Optional
  apiUrl?: string;                 // Override license validation API URL
  files?: File;                    // Initial image file to load
  callback?: (result: CallbackProps, extras?: EditorExtras) => void;
  theme?: Record<string, string>;  // Custom theme colors
  showThemeCreator?: boolean;      // Show theme customization UI
  headless?: boolean;              // Enable programmatic mode
  brands?: BrandDetails[];         // Brand presets
  defaultTemplate?: Template;      // Load template on start

  // Advanced
  onExport?: (templateId: string, brandResults: any, updatedImage: string, thumbUri: string) => void;
  onSaveTemplate?: (props: { brandId: string; template: any }) => Promise<void>;
  onGetTemplates?: (brandIdList?: string[]) => Promise<void | { success: { data: any } }>;
  createAutomatedContent?: CreateAutomatedContent;
  calls?: CallItem[];              // Headless mode: functions to execute
}

Callback Types

interface CallbackProps {
  base64: string;        // Exported image as base64 data URL
  width: number;         // Canvas width
  height: number;        // Canvas height
  template?: any;        // Scene data if template was used
}

interface EditorExtras {
  thumbnail?: string;    // Thumbnail preview
  format?: string;       // Image format (png, jpg, etc)
}

Step-by-Step Integration

Step 1: Create Your Page Component

app/studio/image/page.tsx:
'use client';

import { useState, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';

// Dynamic import to avoid SSR
const ImageEditor = dynamic(
  () => import('@distralabs/media-editor').then(mod => ({ default: mod.ImageEditor })),
  { ssr: false }
);

export default function ImageStudioPage() {
  const router = useRouter();
  const fileInputRef = useRef<HTMLInputElement>(null);

  // State management
  const [selectedFile, setSelectedFile] = useState<File | null>(null);
  const [showEditor, setShowEditor] = useState(false);
  const [exportedImage, setExportedImage] = useState<string | null>(null);

  // Continue to Step 2...
}

Step 2: Implement File Selection

const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
  const file = e.target.files?.[0];
  if (file && file.type.startsWith('image/')) {
    setSelectedFile(file);
    setShowEditor(true);
  }
};

const handleDrop = (e: React.DragEvent) => {
  e.preventDefault();
  const file = e.dataTransfer.files?.[0];
  if (file && file.type.startsWith('image/')) {
    setSelectedFile(file);
    setShowEditor(true);
  }
};

Step 3: Implement Callbacks

const handleExport = useCallback((result: any, extras?: any) => {
  console.log('Export result:', result, extras);

  if (result.base64) {
    setExportedImage(result.base64);
    setShowEditor(false);
    // Show success modal or download
  }
}, []);

const handleClose = () => {
  setShowEditor(false);
  setSelectedFile(null);
};

const handleDownload = () => {
  if (exportedImage) {
    const link = document.createElement('a');
    link.href = exportedImage;
    link.download = `edited-image-${Date.now()}.png`;
    link.click();
  }
};

Step 4: Render the Editor

return (
  <div className="min-h-screen">
    {/* Upload UI */}
    {!showEditor && (
      <div>
        <input
          ref={fileInputRef}
          type="file"
          accept="image/*"
          onChange={handleFileSelect}
          className="hidden"
        />
        <button onClick={() => fileInputRef.current?.click()}>
          Choose Image
        </button>
      </div>
    )}

    {/* Image Editor */}
    {showEditor && selectedFile && (
      <div className="fixed inset-0 z-40">
        <ImageEditor
          licenseKey="YOUR_LICENSE_KEY_HERE"
          apiUrl="https://your-api.com/social"
          files={selectedFile}
          onClose={handleClose}
          callback={handleExport}
          theme={customTheme}
          showThemeCreator={false}
        />
      </div>
    )}
  </div>
);

Theme Customization

Creating a Custom Theme

const customTheme = {
  // Backgrounds
  'background.primary': '#0f172a',      // Main background
  'background.secondary': '#1e293b',    // Secondary panels
  'background.tertiary': '#334155',     // Tertiary elements

  // Text
  'text.primary': '#ffffff',            // Primary text
  'text.secondary': '#cbd5e0',          // Secondary text

  // Brand colors
  'accent.primary': '#3b82f6',          // Primary actions
  'accent.secondary': '#06b6d4',        // Secondary actions
  'accent.hover': '#60a5fa',            // Hover states

  // Borders
  'border.default': '#334155',
  'border.subtle': '#1e293b',

  // Components
  'button.primary': '#3b82f6',
  'input.background': '#1e293b',
  'toolbar.background': '#0f172a',
  'canvas.background': '#1a202c',
};

Applying the Theme

<ImageEditor
  licenseKey={licenseKey}
  files={selectedFile}
  onClose={handleClose}
  callback={handleExport}
  theme={customTheme}
  showThemeCreator={false}  // Hide theme customization UI
/>

Callbacks and Event Handling

Export Callback

Called when user exports the edited image:
const handleExport = useCallback((result: CallbackProps, extras?: EditorExtras) => {
  console.log('Exported image:', {
    base64: result.base64,        // Data URL of image
    width: result.width,          // Canvas width
    height: result.height,        // Canvas height
    thumbnail: extras?.thumbnail  // Optional thumbnail
  });

  // Download the image
  const link = document.createElement('a');
  link.href = result.base64;
  link.download = 'edited-image.png';
  link.click();

  // Or upload to server
  fetch('/api/upload', {
    method: 'POST',
    body: JSON.stringify({ image: result.base64 }),
    headers: { 'Content-Type': 'application/json' }
  });
}, []);

Close Callback

Called when user clicks close/cancel:
const handleClose = () => {
  // Confirm before closing if changes were made
  const confirmed = window.confirm('Discard changes?');
  if (confirmed) {
    setShowEditor(false);
    setSelectedFile(null);
  }
};

Template Callbacks

Save and load custom templates:
const handleSaveTemplate = async ({ brandId, template }: any) => {
  const response = await fetch('/api/templates', {
    method: 'POST',
    body: JSON.stringify({ brandId, template }),
    headers: { 'Content-Type': 'application/json' }
  });
  return response.json();
};

const handleGetTemplates = async (brandIds?: string[]) => {
  const response = await fetch('/api/templates?' + new URLSearchParams({
    brandIds: brandIds?.join(',') || ''
  }));
  return response.json();
};

<ImageEditor
  onSaveTemplate={handleSaveTemplate}
  onGetTemplates={handleGetTemplates}
  // ... other props
/>

Complete Example

app/studio/image/page.tsx:
'use client';

import { useState, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Upload, Download, X } from 'lucide-react';
import { useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';

const ImageEditor = dynamic(
  () => import('@distralabs/media-editor').then(mod => ({ default: mod.ImageEditor })),
  { ssr: false }
);

const customTheme = {
  'background.primary': '#0f172a',
  'background.secondary': '#1e293b',
  'background.tertiary': '#334155',
  'text.primary': '#ffffff',
  'text.secondary': '#cbd5e0',
  'accent.primary': '#3b82f6',
  'accent.secondary': '#06b6d4',
  'accent.hover': '#60a5fa',
  'border.default': '#334155',
  'border.subtle': '#1e293b',
};

export default function ImageStudioPage() {
  const router = useRouter();
  const fileInputRef = useRef<HTMLInputElement>(null);

  const [selectedFile, setSelectedFile] = useState<File | null>(null);
  const [showEditor, setShowEditor] = useState(false);
  const [exportedImage, setExportedImage] = useState<string | null>(null);
  const [showExportModal, setShowExportModal] = useState(false);

  const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file && file.type.startsWith('image/')) {
      setSelectedFile(file);
      setShowEditor(true);
    }
  };

  const handleDrop = (e: React.DragEvent) => {
    e.preventDefault();
    const file = e.dataTransfer.files?.[0];
    if (file && file.type.startsWith('image/')) {
      setSelectedFile(file);
      setShowEditor(true);
    }
  };

  const handleExport = useCallback((result: any, extras?: any) => {
    console.log('Export result:', result, extras);
    if (result.base64) {
      setExportedImage(result.base64);
      setShowExportModal(true);
      setShowEditor(false);
    }
  }, []);

  const handleClose = () => {
    setShowEditor(false);
    setSelectedFile(null);
  };

  const handleDownload = () => {
    if (exportedImage) {
      const link = document.createElement('a');
      link.href = exportedImage;
      link.download = `edited-image-${Date.now()}.png`;
      link.click();
    }
  };

  return (
    <div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900">
      {/* Upload Interface */}
      <AnimatePresence>
        {!showEditor && !showExportModal && (
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            className="flex flex-col items-center justify-center min-h-screen p-6"
          >
            <div
              onDrop={handleDrop}
              onDragOver={(e) => e.preventDefault()}
              onClick={() => fileInputRef.current?.click()}
              className="relative w-full max-w-2xl cursor-pointer"
            >
              <div className="bg-white/10 backdrop-blur-md border-2 border-dashed border-white/30 rounded-3xl p-16 text-center hover:border-purple-400 transition-all">
                <Upload className="w-20 h-20 mx-auto mb-6 text-purple-400" />
                <h3 className="text-2xl font-bold text-white mb-3">
                  Drop your image here
                </h3>
                <p className="text-slate-300 mb-6">
                  or click to browse your files
                </p>
              </div>
              <input
                ref={fileInputRef}
                type="file"
                accept="image/*"
                onChange={handleFileSelect}
                className="hidden"
              />
            </div>
          </motion.div>
        )}
      </AnimatePresence>

      {/* Image Editor */}
      {showEditor && selectedFile && (
        <div className="fixed inset-0 z-40">
          <ImageEditor
            licenseKey="YOUR_LICENSE_KEY_HERE"
            apiUrl="https://localhost:3030/social"
            files={selectedFile}
            onClose={handleClose}
            callback={handleExport}
            theme={customTheme}
            showThemeCreator={false}
          />
        </div>
      )}

      {/* Export Modal */}
      <AnimatePresence>
        {showExportModal && exportedImage && (
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            className="fixed inset-0 z-50 flex items-center justify-center p-6 bg-black/80"
            onClick={() => setShowExportModal(false)}
          >
            <motion.div
              initial={{ scale: 0.8, y: 20 }}
              animate={{ scale: 1, y: 0 }}
              exit={{ scale: 0.8, y: 20 }}
              onClick={(e) => e.stopPropagation()}
              className="bg-slate-800 rounded-2xl p-8 max-w-4xl w-full"
            >
              <div className="flex justify-between items-center mb-6">
                <h2 className="text-3xl font-bold text-white">
                  Your Creation is Ready!
                </h2>
                <button
                  onClick={() => setShowExportModal(false)}
                  className="p-2 hover:bg-white/10 rounded-lg"
                >
                  <X className="w-6 h-6 text-white" />
                </button>
              </div>

              <div className="mb-6 rounded-xl overflow-hidden bg-slate-900">
                <img src={exportedImage} alt="Edited" className="w-full h-auto" />
              </div>

              <div className="flex gap-4">
                <button
                  onClick={handleDownload}
                  className="flex-1 flex items-center justify-center gap-2 px-6 py-4 bg-gradient-to-r from-purple-500 to-pink-500 rounded-xl text-white font-bold"
                >
                  <Download className="w-5 h-5" />
                  Download Image
                </button>
                <button
                  onClick={() => {
                    setShowExportModal(false);
                    setSelectedFile(null);
                    setExportedImage(null);
                  }}
                  className="flex-1 px-6 py-4 bg-white/10 rounded-xl text-white font-bold"
                >
                  Create Another
                </button>
              </div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

Next Steps

  1. See FAQ.md for common issues and troubleshooting
  2. See NEXTJS_VIDEOEDITOR_INTEGRATION.md for video editing
  3. Check the included example project for a complete working implementation

Support

For issues or questions, please visit: