React quick start

Embed ImportFlow in a React app with one drop-in component.

Paste one React component into your app. It loads the real hosted ImportFlow widget and cleans itself up on unmount.

Create a project

Before you paste code

  • Create and verify a dashboard project connected to your customer Supabase app.
  • Create an import kit from one canonical template and confirm the destination table.
  • Copy the kit embed token from the kit detail page.
  • Run the staging SQL from the kit page before testing a real import.

Quick start: React drop-in component

Paste this into an existing React page or component, then replace the example token with your kit token.

'use client'

import { useEffect, useRef } from 'react'

const IMPORTFLOW_APP_ORIGIN = "https://your-importflow-domain.example"
const IMPORTFLOW_TOKEN = "if_pub_xxxxxxxxx"
const IMPORTFLOW_APPEARANCE = {} as const
const IMPORTFLOW_SCRIPT_ID = 'importflow-embed-loader'

type ImportFlowSummary = {
  importJobId: string
  requestedRows: number
  validRows: number
  insertedRows: number
  failedRows: number
  completedAt: string
}

type ImportFlowMount = {
  destroy: () => void
}

type ImportFlowGlobal = {
  mount: (options: {
    token: string
    target: HTMLElement
    theme?: 'light' | 'dark' | 'system'
    primaryColor?: string
    surfaceColor?: string
    textColor?: string
    radius?: 'none' | 'sm' | 'md' | 'lg' | 'xl'
    onComplete?: (summary: ImportFlowSummary) => void
    onClose?: (event: { reason: string; phase: string }) => void
  }) => ImportFlowMount
}

declare global {
  interface Window {
    ImportFlow?: ImportFlowGlobal
  }
}

function clearTarget(target: HTMLElement) {
  target.replaceChildren()
}

function loadImportFlowScript() {
  if (window.ImportFlow) {
    return Promise.resolve()
  }

  const existing = document.getElementById(IMPORTFLOW_SCRIPT_ID) as HTMLScriptElement | null
  if (existing) {
    return new Promise<void>((resolve, reject) => {
      existing.addEventListener('load', () => resolve(), { once: true })
      existing.addEventListener('error', () => reject(new Error('ImportFlow failed to load.')), {
        once: true,
      })
    })
  }

  return new Promise<void>((resolve, reject) => {
    const script = document.createElement('script')
    script.id = IMPORTFLOW_SCRIPT_ID
    script.src = new URL('/embed/v1/importflow.js', IMPORTFLOW_APP_ORIGIN).toString()
    script.async = true
    script.addEventListener('load', () => resolve(), { once: true })
    script.addEventListener('error', () => reject(new Error('ImportFlow failed to load.')), {
      once: true,
    })
    document.head.appendChild(script)
  })
}

export function ImportFlowDropIn() {
  const targetRef = useRef<HTMLDivElement | null>(null)
  const mountRef = useRef<ImportFlowMount | null>(null)

  useEffect(() => {
    const target = targetRef.current
    if (!target) {
      return
    }

    let cancelled = false
    mountRef.current?.destroy()
    mountRef.current = null
    clearTarget(target)

    loadImportFlowScript()
      .then(() => {
        if (cancelled || !window.ImportFlow) {
          return
        }

        mountRef.current?.destroy()
        clearTarget(target)
        mountRef.current = window.ImportFlow.mount({
          token: IMPORTFLOW_TOKEN,
          target,
          ...IMPORTFLOW_APPEARANCE,
          onComplete(summary) {
            console.log('ImportFlow complete', summary)
          },
          onClose(event) {
            console.log('ImportFlow closed', event)
          },
        })
      })
      .catch((error: unknown) => {
        if (!cancelled) {
          console.error(error)
        }
      })

    return () => {
      cancelled = true
      mountRef.current?.destroy()
      mountRef.current = null
      clearTarget(target)
    }
  }, [])

  return <div ref={targetRef} />
}

Quick start vs advanced

The drop-in snippet is the primary path from the kit page: one component, one token, and built-in mount cleanup. The helper file below is optional for teams that want to centralize callbacks or wrapper styling.

Appearance options

Appearance options are passed through hosted widget URLs and generated snippets. Use theme: 'light' | 'dark' | 'system', radius: 'none' | 'sm' | 'md' | 'lg' | 'xl', and optional strict hex color overrides. System follows the visitor device/browser color mode as the base appearance; manual overrides still apply on top.

Advanced customization helper

Advanced path for apps that prefer one shared wrapper component.

'use client'

import { useEffect, useRef } from 'react'

const DEFAULT_IMPORTFLOW_APP_ORIGIN = "https://your-importflow-domain.example"
const SCRIPT_ID = 'importflow-embed-loader'

type ImportFlowReadyPayload = {
  importKitId: string
  template: string
  showWatermark: boolean
}

type ImportFlowCompletePayload = {
  importJobId: string
  requestedRows: number
  validRows: number
  insertedRows: number
  failedRows: number
  completedAt: string
}

type ImportFlowClosePayload = {
  reason: 'user' | 'done' | 'fatal_error'
  phase: string
}

type ImportFlowTheme = 'light' | 'dark' | 'system'
type ImportFlowRadius = 'none' | 'sm' | 'md' | 'lg' | 'xl'

type ImportFlowMount = {
  destroy: () => void
}

type ImportFlowGlobal = {
  mount: (options: {
    token: string
    target: HTMLElement
    theme?: ImportFlowTheme
    primaryColor?: string
    surfaceColor?: string
    textColor?: string
    radius?: ImportFlowRadius
    onReady?: (payload: ImportFlowReadyPayload) => void
    onComplete?: (summary: ImportFlowCompletePayload) => void
    onClose?: (event: ImportFlowClosePayload) => void
  }) => ImportFlowMount
}

declare global {
  interface Window {
    ImportFlow?: ImportFlowGlobal
  }
}

export type ImportFlowEmbedProps = {
  token: string
  appOrigin?: string
  className?: string
  theme?: ImportFlowTheme
  primaryColor?: string
  surfaceColor?: string
  textColor?: string
  radius?: ImportFlowRadius
  onReady?: (payload: ImportFlowReadyPayload) => void
  onComplete?: (summary: ImportFlowCompletePayload) => void
  onClose?: (event: ImportFlowClosePayload) => void
}

function clearTarget(target: HTMLElement) {
  target.replaceChildren()
}

function getScriptUrl(appOrigin: string) {
  return new URL('/embed/v1/importflow.js', appOrigin).toString()
}

function loadImportFlowScript(src: string): Promise<void> {
  if (window.ImportFlow) {
    return Promise.resolve()
  }

  const existing = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null

  if (existing) {
    return new Promise((resolve, reject) => {
      existing.addEventListener('load', () => resolve(), { once: true })
      existing.addEventListener('error', () => reject(new Error('Failed to load ImportFlow.')), {
        once: true,
      })
    })
  }

  return new Promise((resolve, reject) => {
    const script = document.createElement('script')
    script.id = SCRIPT_ID
    script.src = src
    script.async = true
    script.addEventListener('load', () => resolve(), { once: true })
    script.addEventListener('error', () => reject(new Error('Failed to load ImportFlow.')), {
      once: true,
    })
    document.head.appendChild(script)
  })
}

export function ImportFlowEmbed({
  token,
  appOrigin = DEFAULT_IMPORTFLOW_APP_ORIGIN,
  className,
  theme,
  primaryColor,
  surfaceColor,
  textColor,
  radius,
  onReady,
  onComplete,
  onClose,
}: ImportFlowEmbedProps) {
  const targetRef = useRef<HTMLDivElement | null>(null)
  const mountRef = useRef<ImportFlowMount | null>(null)

  useEffect(() => {
    const target = targetRef.current

    if (!target || !token.trim()) {
      return
    }

    let cancelled = false
    mountRef.current?.destroy()
    mountRef.current = null
    clearTarget(target)

    loadImportFlowScript(getScriptUrl(appOrigin))
      .then(() => {
        if (cancelled) {
          return
        }

        if (!window.ImportFlow) {
          throw new Error('ImportFlow loaded without exposing window.ImportFlow.')
        }

        mountRef.current?.destroy()
        clearTarget(target)
        mountRef.current = window.ImportFlow.mount({
          token,
          target,
          ...(theme ? { theme } : {}),
          ...(primaryColor ? { primaryColor } : {}),
          ...(surfaceColor ? { surfaceColor } : {}),
          ...(textColor ? { textColor } : {}),
          ...(radius ? { radius } : {}),
          ...(onReady ? { onReady } : {}),
          ...(onComplete ? { onComplete } : {}),
          ...(onClose ? { onClose } : {}),
        })
      })
      .catch((error: unknown) => {
        if (!cancelled) {
          console.error(error)
        }
      })

    return () => {
      cancelled = true
      mountRef.current?.destroy()
      mountRef.current = null
      clearTarget(target)
    }
  }, [appOrigin, token, theme, primaryColor, surfaceColor, textColor, radius, onReady, onComplete, onClose])

  return <div className={className} ref={targetRef} />
}

Advanced customization usage

Use this only if you added the advanced helper file above.

'use client'

import { ImportFlowEmbed } from '@/components/importflow/importflow-embed'

export function LeadsImport() {
  return <ImportFlowEmbed token="if_pub_xxxxxxxxx" />
}

What the drop-in does

It loads /embed/v1/importflow.js, calls window.ImportFlow.mount(...), and cleans up the hosted widget frame whenever React unmounts or remounts the component.

What stays server-authoritative

The widget still bootstraps through ImportFlow, validates the public embed token, enforces inserted-row limits, writes through the staging table, and finalizes imports server-side.

Need plain JavaScript instead?

Use the same hosted loader without React. The contract is the same widget and token.

Read the JavaScript quick start