File Storage
Docs menu

Framework guides

Next.js guide

App Router: load the CDN once in the root layout and mount through a useFileStorage hook.

What you will build

A Next.js app with the File Storage file manager on /files, connected to your storage, with tests that prove it mounts, licenses and uploads. The steps work the same way in an existing app; skip the scaffolding step.

Prerequisites

  • Node.js 20 LTS or newer.
  • A production API key (fs_live_…) from Dashboard → Keys. Keys only work on an active plan.
  • Your page hosts registered under Dashboard → Websites. Once any website is registered, the key only works on those hosts, so add localhost for development alongside your production domain.
  • A storage backend: your own S3 proxy or Managed storage (Free includes 1 GB). See Choose storage.

1. Create the project

Skip this if you already have a Next.js app. Accept the default @/* import alias.

Terminal
npx create-next-app@latest my-files-app --typescript --app --eslint
cd my-files-app

2. Load the SDK in the root layout

next/script with afterInteractive loads the CDN once for every route. The stylesheet goes in <head>.

app/layout.tsx
import Script from 'next/script'
import type { ReactNode } from 'react'

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <head>
        <link rel="stylesheet" href="https://cdn.filestorage.io/file-storage.css" />
      </head>
      <body>
        {children}
        <Script src="https://cdn.filestorage.io/file-storage.min.js" strategy="afterInteractive" />
      </body>
    </html>
  )
}

3. Add environment variables

Create .env.local in the project root.

.env.local
NEXT_PUBLIC_FILESTORAGE_API_KEY=fs_live_…
NEXT_PUBLIC_FM_PROXY_URL=http://localhost:3847

4. Type the global

Any .d.ts file in the project is picked up by the default tsconfig.json.

filestorage.d.ts
export {}

declare global {
  interface FileStorageMountOptions {
    el: string | HTMLElement
    apiKey: string
    s3?: {
      proxyBaseUrl: string
      publicObjectBaseUrl?: string
      headers?: () => Record<string, string>
    }
    http?: {
      baseUrl: string
      headers?: () => Record<string, string>
    }
    widget?: 'full' | 'picker' | 'upload' | 'media'
    accessMode?: 'full' | 'read-only' | 'upload-only'
  }

  interface Window {
    FileStorageSDK?: {
      mount(options: FileStorageMountOptions): { unmount(): void }
    }
  }
}

5. Create the hook

Because the script loads after hydration, the hook waits for window.FileStorageSDK before mounting, and unmounts on navigation away.

lib/useFileStorage.ts
'use client'
import { useEffect, useRef } from 'react'

export function useFileStorage(options: Omit<FileStorageMountOptions, 'el'>) {
  const ref = useRef<HTMLDivElement>(null)

  useEffect(() => {
    let handle: { unmount(): void } | undefined
    let timer: ReturnType<typeof setInterval> | undefined

    const tryMount = () => {
      if (!ref.current || !window.FileStorageSDK) return false
      handle = window.FileStorageSDK.mount({ el: ref.current, ...options })
      return true
    }

    if (!tryMount()) {
      timer = setInterval(() => {
        if (tryMount()) clearInterval(timer)
      }, 50)
    }

    return () => {
      clearInterval(timer)
      handle?.unmount()
    }
    // Mount once per page; remount by changing the component key.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  return ref
}

6. Add a page

The page is a Client Component because it touches window.

app/files/page.tsx
'use client'
import { useFileStorage } from '@/lib/useFileStorage'

export default function FilesPage() {
  const ref = useFileStorage({
    apiKey: process.env.NEXT_PUBLIC_FILESTORAGE_API_KEY!,
    s3: { proxyBaseUrl: process.env.NEXT_PUBLIC_FM_PROXY_URL! },
  })

  return <div ref={ref} style={{ height: '70vh' }} />
}

Choose storage

The UI is the same either way; only the mount option changes.

Your S3 proxy

The examples above already use this: s3.proxyBaseUrl points at a small server you run that holds the AWS credentials. Locally that is usually http://localhost:3847. The routes it must implement are in the S3 proxy contract; for PHP, Laravel or other stacks see Backends & cloud.

Allow your app origin on the proxy (http://localhost:3000 in development) or uploads will fail with CORS errors.

Managed storage

File Storage provisions an isolated prefix for your account on our AWS bucket, so there is no proxy to run and no AWS keys to hold. Pass http instead of s3 to the hook:

app/files/page.tsx
const apiKey = process.env.NEXT_PUBLIC_FILESTORAGE_API_KEY!

const ref = useFileStorage({
  apiKey,
  http: {
    baseUrl: 'https://api.filestorage.io/v1/managed',
    headers: () => ({ 'X-FileStorage-Api-Key': apiKey }),
  },
})

The exact base URL and your tenant status are shown in Dashboard → Managed storage.

Run it locally

Terminal
npm run dev

Open http://localhost:3000/files. You should see a short license check, then the file manager listing the root folder.

Test

Manual checklist

With DevTools open on the Network tab, reload http://localhost:3000/files and confirm:

  1. POST https://api.filestorage.io/v1/license/validate returns 200 with "valid": true.
  2. The first list call returns 200: /list on your proxy, or /v1/managed/api/files/list on Managed.
  3. Upload a file, then rename it, move it into a new folder, and delete it. Each action should succeed without a console error.
  4. Reload the page. The folder you created is still there, which proves the files reached storage.
  5. Navigate away and back (or unmount the component). Exactly one file manager appears.

Unit test

Stub window.FileStorageSDK and test both paths: SDK already loaded, and SDK arriving after the page renders.

Terminal
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react vite-tsconfig-paths
vitest.config.mts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
  plugins: [tsconfigPaths(), react()],
  test: { environment: 'jsdom' },
})
__tests__/files-page.test.tsx
import { render } from '@testing-library/react'
import { afterEach, expect, test, vi } from 'vitest'
import FilesPage from '@/app/files/page'

const unmount = vi.fn()
const mount = vi.fn(() => ({ unmount }))

afterEach(() => {
  vi.clearAllMocks()
  vi.useRealTimers()
  delete window.FileStorageSDK
})

test('mounts immediately when the SDK is loaded', () => {
  window.FileStorageSDK = { mount }
  const view = render(<FilesPage />)

  expect(mount).toHaveBeenCalledWith(
    expect.objectContaining({ el: expect.any(HTMLDivElement) }),
  )
  view.unmount()
  expect(unmount).toHaveBeenCalledTimes(1)
})

test('waits for the CDN script, then mounts', () => {
  vi.useFakeTimers()
  render(<FilesPage />)
  expect(mount).not.toHaveBeenCalled()

  window.FileStorageSDK = { mount }
  vi.advanceTimersByTime(50)
  expect(mount).toHaveBeenCalledTimes(1)
})
Terminal
npx vitest run

End-to-end (Playwright)

This runs your real app in a browser against the real license API and storage, so it catches what unit tests cannot: CDN loading, domain registration, CORS and uploads.

Terminal
npm init playwright@latest -- --quiet --lang=ts --gha
playwright.config.ts
import { defineConfig } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  use: { baseURL: 'http://localhost:3000' },
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})
e2e/file-manager.spec.ts
import { expect, test } from '@playwright/test'

test('File Storage mounts with a valid license', async ({ page }) => {
  const license = page.waitForResponse((r) => r.url().includes('/v1/license/validate'))
  await page.goto('/files')

  expect((await license).status()).toBe(200)
  await expect(page.locator('[data-filestorage-license="denied"]')).toHaveCount(0)
  await expect(page.getByRole('button', { name: 'Upload', exact: true })).toBeVisible()
})

test('uploads a file into the current folder', async ({ page }) => {
  const name = `e2e-${Date.now()}.txt`
  await page.goto('/files')

  await page.getByRole('button', { name: 'Upload', exact: true }).click()
  const dialog = page.getByRole('dialog', { name: 'Upload files' })
  await dialog.locator('input[type="file"]').setInputFiles({
    name,
    mimeType: 'text/plain',
    buffer: Buffer.from('hello from playwright'),
  })
  await dialog.getByRole('button', { name: /^Upload \(1\)$/ }).click()

  await expect(page.getByText(name).first()).toBeVisible()
})
Terminal
npx playwright test

Go to production

  1. Add your production domain under Dashboard → Websites.
  2. Set the production API key and storage URL in your host’s environment settings.
  3. Build with npm run build and deploy the output.
  4. If you use a Content Security Policy, allow cdn.filestorage.io in script-src and style-src, and api.filestorage.io plus your proxy origin in connect-src.
  5. Run the Playwright suite against the deployed URL (set baseURL) as a smoke test.

Troubleshooting

SymptomFix
window is not defined during buildThe file that calls the hook needs 'use client' at the top.
Works locally, API key empty in productionNEXT_PUBLIC_* values are baked in at build time. Set them in your host (e.g. Vercel project settings) and rebuild.
FileStorageSDK is undefinedThe CDN script did not load before mount. Check the Network tab for a 200 on file-storage.min.js and any Content Security Policy errors.
DOMAIN_NOT_ALLOWEDThe page host is not in Dashboard → Websites. Add localhost for local work and your production domain before deploying.
UNPAID_PLAN or INVALID_API_KEYUse a non-revoked production key from an account with an active plan.
CORS error on /list or /uploadYour proxy must allow the app origin (http://localhost:3000 locally) for POST with Content-Type and multipart bodies.
File manager renders with zero heightThe host element needs an explicit height (the examples use 70vh).

All mount options, store contracts and widget modes are in the integration guide.