PotaLab
← All examples
SnippetStorageRLSNext.js

File upload with Storage

Create a private bucket (dashboard or a server client with a secret key), then add policies on storage.objects so each user only touches the folder named after their id. The Server Component lists the files for the first paint.

localhost:3000/documents

Documents

Private files, only visible to you.

  • report.pdf248 KB
  • logo.png36 KB
  • invoice-march.pdf112 KB

Stored under documents/<your user id>/. Links are signed and expire after 60 s.

Preview · sample data
'use client'
import { useEffect, useState } from 'react'
import type { FileEntry } from '@potalab/base'
import { createBrowserClient } from '@potalab/base/next'
const base = () =>
createBrowserClient({ url: process.env.NEXT_PUBLIC_POTALAB_BASE_URL!, key: process.env.NEXT_PUBLIC_POTALAB_BASE_KEY! })
export function Documents({ userId, initialFiles }: { userId: string; initialFiles: FileEntry[] }) {
const [files, setFiles] = useState(initialFiles)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return <p>Loading...</p>
const bucket = base().storage.from('documents') // private bucket
const refresh = async () => {
const { data } = await bucket.list(`${userId}/`, { limit: 100 })
setFiles((data ?? []).filter((f) => f.id)) // folders have id: null
}
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setBusy(true)
// Bytes go straight to object storage; the path must start with your user id (storage policy)
const { error } = await bucket.upload(`${userId}/${file.name}`, file, {
contentType: file.type,
upsert: true,
})
setError(error?.message ?? null)
setBusy(false)
if (!error) await refresh()
}
async function share(name: string) {
// Private bucket: short-lived signed URL (60 s)
const { data } = await bucket.createSignedUrl(`${userId}/${name}`, 60)
if (data) window.open(data.signedUrl, '_blank')
}
async function remove(name: string) {
await bucket.remove([`${userId}/${name}`])
await refresh()
}
return (
<div>
<label>
{busy ? 'Uploading...' : 'Choose a file to upload'}
<input type="file" onChange={onPick} disabled={busy} />
</label>
{error && <p role="alert">{error}</p>}
<ul>
{files.map((f) => (
<li key={f.name}>
<span>{f.name}</span>
<span>{f.size != null ? `${Math.round(f.size / 1024)} KB` : ''}</span>
<button onClick={() => share(f.name)}>Get link</button>
<button onClick={() => remove(f.name)}>Delete</button>
</li>
))}
</ul>
</div>
)
}

Snippet for illustration, written for the Next.js App Router with @potalab/base/next,@potalab/base/react and @potalab/base. Set NEXT_PUBLIC_POTALAB_BASE_URL andNEXT_PUBLIC_POTALAB_BASE_KEY, adapt names and redirect URLs, and review policies before production.

Next: Realtime chat →