DEV Community

Voltage
Voltage

Posted on

How to Add Multi-Tenant File Storage to Your Next.js project in 10 Minutes.

Introduction

If you have worked on Saas projects before, there's a good chance you've already hit this wall.
Your users need to upload files. So you spin up a storage bucket from your favorite storage provider like AWS S3, Cloudflare R2 etc, wire up some SDK code, and ship it. But then the questions start piling up:

  • How do you track how much storage each user is consuming?
  • How do you enforce storage limits per user without building a whole quota system from scratch?
  • How do you make sure User A can never access User B's files?

Most developers answer these questions by inventing a folder naming convention like user_123/uploads/avatar.jpg and then build manual tracking on top. It works. But it takes a week to do properly, and it has nothing to do with your actual product.

Here's what that week actually looks like:
You spin up one big shared bucket for example from S3 or R2. Every file from every user lands in the same place. So you invent a prefix naming scheme and pray your logic never has a bug that leaks User A's files to User B. Then you build usage tracking a database column here, a counter there. Remember to decrement on deletion. Hope nothing drifts. Then quotas. Check before every upload. Return a meaningful error. Handle edge cases. Then presigned URLs. SDK config. IAM permissions. CORS rules.
By the time you're done, you've spent a week on plumbing that has nothing to do with your product.
I've done this three times and it is painful.
In this guide you'll integrate Tenantbox into a Next.js app from scratch. By the end you'll have per-tenant file isolation, automatic usage tracking, and working upload, download, and delete flows.

What is Tenantbox?

Tenantbox sits between your application and your cloud storage. You send it a tenant_id (your user's ID, email or username from your own database) and a filename. It returns a presigned URL. You PUT the file directly to that URL from the client. The file never touches your server.
Tenants are created automatically on first upload. No provisioning, no per-user setup required.

Tenantbox dashboard showing a project with two tenants, their file counts and storage usage

Compared to managing storage yourself:

  • Raw cloud storage like R2 or S3 gives you a bucket. You build everything on top.

  • Tenantbox gives you per-tenant isolation, usage tracking, and quota enforcement out of the box in the time it takes to make two HTTP requests.

The difference isn't the storage layer. The difference is the week of plumbing you no longer have to write.

Prerequisites

  • Node.js 18 or later
  • A Next.js project (we'll create one from scratch)
  • A Tenantbox account - sign up free at tenantbox.dev

Step 1 - Sign up and get your API key

Head to tenantbox.dev and create an account. After verifying your email you'll land on the dashboard.

Tenantbox empty dashboard

Click New project, give it a name, and copy the API key shown. This is the only time the raw key is displayed store it somewhere safe immediately.

Tenantbox

Step 2 - Create a Next.js project

npx create-next-app@latest tenantbox-nextjs-demo
Enter fullscreen mode Exit fullscreen mode

Answer the prompts like this:

✔ Would you like to use TypeScript? → Yes
✔ Would you like to use ESLint? → Yes
✔ Would you like to use Tailwind CSS? → Yes
✔ Would you like to use App Router? → Yes
Enter fullscreen mode Exit fullscreen mode

Then enter the project and install dependencies:

cd tenantbox-nextjs-demo
npm install
Enter fullscreen mode Exit fullscreen mode

Step 3 - Add your API key alongside api base url

Create .env.local in the project root:

TENANTBOX_API_KEY=your_tenantbox_api_key_here
TENANTBOX_BASE_URL=https://api.tenantbox.dev
Enter fullscreen mode Exit fullscreen mode

Replace the value with your actual API key from Step 1. This file is gitignored by default, your key never leaves your machine.

Step 4 - Create the API routes

These are thin server-side wrappers that keep your API key out of client-side code. Create the folder structure:

componennts/
  FileUploader.tsx
app/
  api/
    upload/
      route.ts
    download/
      route.ts
    delete/
      route.ts
    usage/
      route.ts
Enter fullscreen mode Exit fullscreen mode

Upload route - app/api/upload/route.ts
This route receives the tenant ID and filename from your frontend, requests a presigned URL from Tenantbox, and returns it to the client.

import { NextRequest, NextResponse } from 'next/server'

const TENANTBOX_API_KEY  = process.env.TENANTBOX_API_KEY!
const TENANTBOX_BASE_URL = process.env.TENANTBOX_BASE_URL

export async function POST(request: NextRequest) {
  const { tenantId, filename, contentType } = await request.json()

  if (!tenantId || !filename) {
    return NextResponse.json(
      { error: 'tenantId and filename are required' },
      { status: 400 }
    )
  }

  const response = await fetch(`${TENANTBOX_BASE_URL}/api/storage/upload/`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${TENANTBOX_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      tenant_id:    tenantId,
      filename,
      content_type: contentType,
    }),
  })

  const data = await response.json()
  return NextResponse.json(data, { status: response.status })
}
Enter fullscreen mode Exit fullscreen mode

Download route - app/api/download/route.ts
Given a file path, this returns a time-limited presigned download URL the client can open directly.

import { NextRequest, NextResponse } from 'next/server'

const TENANTBOX_API_KEY  = process.env.TENANTBOX_API_KEY!
const TENANTBOX_BASE_URL = process.env.TENANTBOX_BASE_URL

export async function POST(request: NextRequest) {
  const { filePath } = await request.json()

  const response = await fetch(`${TENANTBOX_BASE_URL}/api/storage/download/`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${TENANTBOX_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      file_path:  filePath,
      expires_in: 3600,
    }),
  })

  const data = await response.json()
  return NextResponse.json(data, { status: response.status })
}
Enter fullscreen mode Exit fullscreen mode

Delete route - app/api/delete/route.ts
Permanently removes a file from storage and decrements the tenant's usage automatically.

import { NextRequest, NextResponse } from 'next/server'

const TENANTBOX_API_KEY  = process.env.TENANTBOX_API_KEY!
const TENANTBOX_BASE_URL = process.env.TENANTBOX_BASE_URL

export async function DELETE(request: NextRequest) {
  const { filePath } = await request.json()

  const response = await fetch(`${TENANTBOX_BASE_URL}/api/storage/files/`, {
    method: 'DELETE',
    headers: {
      'Authorization': `Bearer ${TENANTBOX_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ file_path: filePath }),
  })

  const data = await response.json()
  return NextResponse.json(data, { status: response.status })
}
Enter fullscreen mode Exit fullscreen mode

Usage route - app/api/usage/route.ts
Returns current storage stats for a given tenant bytes used, file count, and quota if set.

import { NextRequest, NextResponse } from 'next/server'

const TENANTBOX_API_KEY  = process.env.TENANTBOX_API_KEY!
const TENANTBOX_BASE_URL = process.env.TENANTBOX_BASE_URL

export async function GET(request: NextRequest) {
  const tenantId = request.nextUrl.searchParams.get('tenantId')

  const response = await fetch(
    `${TENANTBOX_BASE_URL}/api/storage/tenants/${tenantId}/usage/`,
    {
      headers: { 'Authorization': `Bearer ${TENANTBOX_API_KEY}` },
    }
  )

  const data = await response.json()
  return NextResponse.json(data, { status: response.status })
}
Enter fullscreen mode Exit fullscreen mode

Step 5 - Build the file uploader component

Create components/FileUploader.tsx. if not alraedy created. This component handles everything on the client side from file selection, direct upload to storage with a real progress bar, and success/error feedback.
The key part is the two-step upload flow:

  1. Call your Next.js API route to get the presigned URL
  2. Use XMLHttpRequest directly to PUT the file to storage fetch doesn't support upload progress events, but XHR does.
'use client'

import { useState, useCallback } from 'react'

interface UploadedFile {
  filePath: string
  filename: string
  size:     number
}

interface FileUploaderProps {
  tenantId:          string
  onUploadComplete?: (file: UploadedFile) => void
}

export default function FileUploader({ tenantId, onUploadComplete }: FileUploaderProps) {
  const [progress,  setProgress]  = useState(0)
  const [uploading, setUploading] = useState(false)
  const [error,     setError]     = useState<string | null>(null)
  const [success,   setSuccess]   = useState(false)

  const uploadFile = useCallback(async (file: File) => {
    setUploading(true)
    setError(null)
    setSuccess(false)
    setProgress(0)

    try {
      // Step 1 — get presigned URL
      const res = await fetch('/api/upload', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          tenantId,
          filename:    file.name,
          contentType: file.type || 'application/octet-stream',
        }),
      })

      if (!res.ok) {
        const err = await res.json()
        throw new Error(err.detail || 'Failed to get upload URL')
      }

      const { presigned_url, file_path } = await res.json()

      // Step 2 — PUT directly to storage
      await new Promise<void>((resolve, reject) => {
        const xhr = new XMLHttpRequest()

        xhr.upload.addEventListener('progress', (e) => {
          if (e.lengthComputable) {
            setProgress(Math.round((e.loaded / e.total) * 100))
          }
        })

        xhr.addEventListener('load', () => {
          xhr.status === 200 ? resolve() : reject(new Error('Upload failed'))
        })

        xhr.addEventListener('error', () => reject(new Error('Network error')))

        xhr.open('PUT', presigned_url)
        xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
        xhr.send(file)
      })

      setSuccess(true)
      onUploadComplete?.({ filePath: file_path, filename: file.name, size: file.size })

    } catch (err: any) {
      setError(err.message)
    } finally {
      setUploading(false)
    }
  }, [tenantId, onUploadComplete])

  return (
    <div className="flex flex-col gap-3">
      <label className={`
        flex flex-col items-center justify-center w-full h-36
        border-2 border-dashed rounded-xl cursor-pointer transition-colors
        ${uploading
          ? 'border-yellow-300 bg-yellow-50 cursor-not-allowed'
          : 'border-gray-300 hover:border-yellow-400 hover:bg-gray-50'
        }
      `}>
        <div className="flex flex-col items-center gap-2 text-sm text-gray-500">
          {uploading ? (
            <>
              <div className="w-6 h-6 border-2 border-yellow-500 border-t-transparent rounded-full animate-spin" />
              <span>Uploading... {progress}%</span>
            </>
          ) : success ? (
            <>
              <span className="text-2xl"></span>
              <span className="text-green-600 font-medium">Upload complete!</span>
              <span className="text-xs text-gray-400">Click to upload another</span>
            </>
          ) : (
            <>
              <span className="text-2xl">📁</span>
              <span>Click to select a file</span>
              <span className="text-xs text-gray-400">Any file type supported</span>
            </>
          )}
        </div>
        <input
          type="file"
          className="hidden"
          onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadFile(f) }}
          disabled={uploading}
        />
      </label>

      {uploading && (
        <div className="w-full h-2 bg-gray-200 rounded-full overflow-hidden">
          <div
            className="h-full bg-yellow-500 rounded-full transition-all duration-200"
            style={{ width: `${progress}%` }}
          />
        </div>
      )}

      {error && (
        <div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
          {error}
        </div>
      )}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

The file uploader component showing the drop zone in its idle state

The file uploader component mid-upload showing the progress bar at ~60%

The file uploader component showing the green success state after upload completes

Step 6 - Build the simple dashboard page

Replace app/page.tsx with the full demo page. This page demonstrates the core value of Tenantbox, you can switch between two simulated tenants (Alice and Bob) and see that their files are completely isolated from each other.

'use client'

import { useState, useEffect } from 'react'
import FileUploader from './components/FileUploader'

interface UserFile {
  filePath: string
  filename: string
  size:     number
}

interface Usage {
  tenant_id:           string
  storage_used_bytes:  number
  storage_limit_bytes: number | null
  total_files:         number
}

// Simulating two different users to show tenant isolation
const TENANTS = [
  { id: 'alice_001', name: 'Alice', color: 'yellow' },
  { id: 'bob_002',   name: 'Bob',   color: 'emerald' },
]

function formatBytes(bytes: number): string {
  if (bytes === 0) return '0 B'
  const k     = 1024
  const sizes = ['B', 'KB', 'MB', 'GB']
  const i     = Math.floor(Math.log(bytes) / Math.log(k))
  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
}

export default function Home() {
  const [activeTenant, setActiveTenant] = useState(TENANTS[0])
  const [files,         setFiles]         = useState<Record<string, UserFile[]>>({
    alice_001: [],
    bob_002:   [],
  })
  const [usage,        setUsage]        = useState<Record<string, Usage | null>>({})
  const [downloading,  setDownloading]  = useState<string | null>(null)
  const [deleting,     setDeleting]     = useState<string | null>(null)

  async function fetchUsage(tenantId: string) {
    try {
      const res   = await fetch(`/api/usage?tenantId=${tenantId}`)
      const data  = await res.json()
      setUsage(prev => ({ ...prev, [tenantId]: data }))
    } catch {}
  }

  useEffect(() => {
    TENANTS.forEach(t => fetchUsage(t.id))
  }, [])

  function handleUploadComplete(file: UserFile) {
    setFiles(prev => ({
      ...prev,
      [activeTenant.id]: [...(prev[activeTenant.id] || []), file],
    }))
    fetchUsage(activeTenant.id)
  }

  async function handleDownload(filePath: string) {
    setDownloading(filePath)
    try {
      const res            = await fetch('/api/download', {
        method:  'POST',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify({ filePath }),
      })
      const { download_url } = await res.json()
      window.open(download_url, '_blank')
    } finally {
      setDownloading(null)
    }
  }

  async function handleDelete(filePath: string) {
    if (!confirm('Delete this file? This cannot be undone.')) return
    setDeleting(filePath)
    try {
      await fetch('/api/delete', {
        method:  'DELETE',
        headers: { 'Content-Type': 'application/json' },
        body:    JSON.stringify({ filePath }),
      })
      setFiles(prev => ({
        ...prev,
        [activeTenant.id]: prev[activeTenant.id].filter(f => f.filePath !== filePath),
      }))
      fetchUsage(activeTenant.id)
    } finally {
      setDeleting(null)
    }
  }

  const currentFiles = files[activeTenant.id] || []
  const currentUsage = usage[activeTenant.id]

  return (
    <div className="min-h-screen bg-gray-950 text-gray-100">
      <div className="max-w-4xl mx-auto px-6 py-10 space-y-8">
        {/* Intro */}
        <div>
          <h1 className="text-2xl font-bold mb-2">Multi-Tenant File Storage Demo</h1>
          <p className="text-gray-400 text-sm leading-relaxed">
            Switch between tenants to see how files are isolated per user.
            Alice and Bob share the same project but can never see each other's files.
          </p>
        </div>

        {/* Tenant switcher */}
        <div>
          <p className="text-xs text-gray-500 uppercase tracking-wider mb-3">Active tenant</p>
          <div className="flex gap-3">
            {TENANTS.map(tenant => (
              <button
                key={tenant.id}
                onClick={() => setActiveTenant(tenant)}
                className={`
                  flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors border
                  ${activeTenant.id === tenant.id
                    ? 'bg-yellow-500/20 border-yellow-500/50 text-yellow-300'
                    : 'bg-gray-900 border-gray-700 text-gray-400 hover:text-gray-200'
                  }
                `}
              >
                <div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold
                  ${tenant.name === 'Alice' ? 'bg-yellow-500' : 'bg-emerald-500'}
                `}>
                  {tenant.name[0]}
                </div>
                {tenant.name}
                <code className="text-xs opacity-60">{tenant.id}</code>
              </button>
            ))}
          </div>
        </div>

        <div className="grid md:grid-cols-3 gap-6">

          {/* Upload + files panel */}
          <div className="md:col-span-2 space-y-6">

            {/* Uploader */}
            <div className="bg-gray-900 border border-gray-800 rounded-xl p-5 space-y-4">
              <h2 className="text-sm font-medium text-gray-300">
                Upload a file as <span className="text-yellow-400">{activeTenant.name}</span>
              </h2>
              <FileUploader
                tenantId={activeTenant.id}
                onUploadComplete={handleUploadComplete}
              />
            </div>

            {/* Files list */}
            <div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
              <div className="px-5 py-4 border-b border-gray-800">
                <h2 className="text-sm font-medium text-gray-300">
                  {activeTenant.name}'s files
                  <span className="ml-2 text-xs text-gray-600">({currentFiles.length} uploaded this session)</span>
                </h2>
              </div>

              {currentFiles.length === 0 ? (
                <div className="px-5 py-12 text-center text-gray-600 text-sm">
                  No files uploaded yet for {activeTenant.name}
                </div>
              ) : (
                <table className="w-full text-sm">
                  <thead>
                    <tr className="border-b border-gray-800">
                      <th className="text-left px-5 py-3 text-xs font-medium text-gray-500">Filename</th>
                      <th className="text-left px-5 py-3 text-xs font-medium text-gray-500">Size</th>
                      <th className="px-5 py-3" />
                    </tr>
                  </thead>
                  <tbody>
                    {currentFiles.map(file => (
                      <tr key={file.filePath} className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors">
                        <td className="px-5 py-3 font-mono text-xs text-gray-300 truncate max-w-xs">
                          {file.filename}
                        </td>
                        <td className="px-5 py-3 text-xs text-gray-500">
                          {formatBytes(file.size)}
                        </td>
                        <td className="px-5 py-3">
                          <div className="flex items-center gap-3 justify-end">
                            <button
                              onClick={() => handleDownload(file.filePath)}
                              disabled={downloading === file.filePath}
                              className="text-xs text-yellow-400 hover:text-yellow-300 disabled:opacity-40 transition-colors"
                            >
                              {downloading === file.filePath ? 'Loading...' : 'Download'}
                            </button>
                            <button
                              onClick={() => handleDelete(file.filePath)}
                              disabled={deleting === file.filePath}
                              className="text-xs text-red-400 hover:text-red-300 disabled:opacity-40 transition-colors"
                            >
                              {deleting === file.filePath ? 'Deleting...' : 'Delete'}
                            </button>
                          </div>
                        </td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              )}
            </div>
          </div>

          {/* Usage sidebar */}
          <div className="space-y-4">
            <div className="bg-gray-900 border border-gray-800 rounded-xl p-5 space-y-4">
              <h2 className="text-sm font-medium text-gray-300">Storage usage</h2>

              {TENANTS.map(tenant => {
                const u = usage[tenant.id]
                return (
                  <div
                    key={tenant.id}
                    className={`p-3 rounded-lg border transition-colors ${
                      activeTenant.id === tenant.id
                        ? 'border-yellow-500/30 bg-yellow-500/5'
                        : 'border-gray-800'
                    }`}
                  >
                    <div className="flex items-center gap-2 mb-2">
                      <div className={`w-5 h-5 rounded-full flex items-center justify-center text-xs font-bold
                        ${tenant.name === 'Alice' ? 'bg-yellow-500' : 'bg-emerald-500'}
                      `}>
                        {tenant.name[0]}
                      </div>
                      <span className="text-xs font-medium text-gray-300">{tenant.name}</span>
                    </div>
                    {u ? (
                      <div className="space-y-1">
                        <div className="flex justify-between text-xs text-gray-500">
                          <span>{formatBytes(u.storage_used_bytes)} used</span>
                          <span>{u.total_files} files</span>
                        </div>
                      </div>
                    ) : (
                      <div className="text-xs text-gray-600">Loading...</div>
                    )}
                  </div>
                )
              })}

              <p className="text-xs text-gray-600 leading-relaxed">
                Each tenant's storage is tracked independently. Alice's usage never affects Bob's.
              </p>
            </div>

            {/* Code snippet */}
            <div className="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
              <div className="px-4 py-2 border-b border-gray-800">
                <span className="text-xs text-gray-500">The upload call</span>
              </div>
              <pre className="text-xs text-yellow-400 p-4 leading-relaxed overflow-x-auto">{`POST /api/storage/upload/
                {
                  "tenant_id": "${activeTenant.id}",
                  "filename": "document.pdf"
                }

                ← presigned_url
                ← file_path`}</pre>
            </div>
          </div>
        </div>
      </div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Step 7 - Run the application

npm run dev
Enter fullscreen mode Exit fullscreen mode

Open http://192.168.100.6:3000/ on your favorite browser

The full dashboard page loaded in the browser

Step 8 - See tenant isolation in action

This is the part that makes Tenantbox worth using. Upload a file as Alice, then switch to Bob.

Upload a file as Alice:

Selecting Alice as the active tenant, then uploading a file the file appears in Alice's list with filename and size

Switch to Bob - Alice's file is gone:

Bob selected as active tenant — his file list is completely empty even though Alice has files

Upload a file as Bob:

Bob now has his own file. Switching back to Alice shows only her file. The two tenants are fully isolated.

This isolation happens with zero code on your end. You passed a tenant_id, Tenantbox namespaced everything automatically.

Step 9 - Check usage in the Tenantbox dashboard

After uploading a few files, go back to your Tenantbox project page. You'll see both tenants listed with their individual storage usage and file counts.

Tenantbox project detail page showing Alice and Bob as separate tenants with their respective file counts and storage used

Let us see Alice's files.

Clicking into Alice's tenant detail page showing her specific files with filenames, sizes, and upload timestamps

Step 10 - Set a storage limit per tenant (optional)

Want to enforce per-user quotas? One API call is all it takes. When a user signs up on your free tier, set their limit:

// Set a 500MB limit for a free tier user
await fetch('https://api.tenantbox.dev/api/storage/tenants/alice_001/limit/', {
  method: 'PATCH',
  headers: {
    'Authorization': `Bearer ${process.env.TENANTBOX_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ storage_limit_bytes: 524288000 }), // 500 MB
})
Enter fullscreen mode Exit fullscreen mode

You can also set quotas from Tenantbox Project's page by clicking on the Pen icon on the Limit column and setting the size in MB's.

Set tenant storage limit from Tenantbox

Once set, any upload that would exceed the limit returns a 413 response before anything reaches storage. No custom quota logic needed on your end.

What you did not have to write
Take a moment to notice what's missing from this guide:

❌ No folder naming convention
❌ No per-user prefix logic
❌ No usage tracking columns or database triggers
❌ No quota check before uploads
❌ No SDK configuration
❌ No IAM permissions
❌ No CORS setup
Enter fullscreen mode Exit fullscreen mode

You wrote three thin API routes and a React component. Tenantbox handled the rest.

What to do next

Save file_path to your database. The file_path returned on upload is the permanent reference you need for future downloads and deletes. In a real app, store it in your database alongside the record it belongs to, a message, a document, a profile, whatever the file is associated with.

// Example — save to your own database after upload completes
await db.files.create({
  userId:   currentUser.id,
  filePath: uploadResult.file_path,  // ← this is what Tenantbox returned
  filename: file.name,
  size:     file.size,
})
Enter fullscreen mode Exit fullscreen mode

Handle quota exceeded errors. When a tenant hits their storage limit, Tenantbox returns a 413 before the presigned URL is even generated. Catch it and show your user a meaningful message:

if (res.status === 413) {
  setError('You have reached your storage limit. Please delete some files or upgrade your plan.')
  return
}
Enter fullscreen mode Exit fullscreen mode

Use the tenant ID from your auth session. In this demo we hardcoded alice_001 and bob_002. In a real app pull the tenant ID from your authentication session so each logged-in user always uploads to their own namespace:

// Next.js App Router with next-auth
import { getServerSession } from 'next-auth'

const session = await getServerSession()
const tenantId = session?.user?.id  // your user's ID becomes the tenant_id
Enter fullscreen mode Exit fullscreen mode

Full API reference

Everything Tenantbox can do is documented at tenantbox.dev/apidocs:

  • Upload files
  • Download files with presigned URLs
  • Delete files
  • Get tenant usage
  • Set and remove storage quotas
  • List files per tenant

Try it yourself

Tenantbox is free to get started at tenantbox.dev
The full source code for this demo is available at https://github.com/hemarastylepeke/tenantbox-nextjs-demo.git.
If you run into anything or have questions, drop a comment below.

Top comments (0)