Accessing Files
Retrieve private files with signed URLs, serve public assets via CDN, and list files with pagination using @nebutra/uploads.
Once a file is uploaded to your storage bucket, you need to serve it back to users. @nebutra/uploads provides helpers for signed URLs (private files), public CDN delivery, and paginated file listing.
Signed URLs for private files
Private files require a time-limited signed URL. The URL is generated server-side and grants temporary read access without exposing your storage credentials.
import { getUploadProvider } from "@nebutra/uploads";
const uploads = await getUploadProvider();
// Generate a signed URL valid for 1 hour (3600 seconds)
const url = await uploads.getSignedUrl(
"nebutra-uploads",
`${tenantId}/docs/report.pdf`,
{ expiresIn: 3600 }
);
// url → https://nebutra-uploads.s3.amazonaws.com/org_acme/docs/report.pdf?X-Amz-Expires=3600&...Signed URL expiry best practices
| Use case | Recommended expiry |
|---|---|
| Inline display (images, PDFs in browser) | 1 hour (3600 s) |
| Download link shown in UI | 15 minutes (900 s) |
| Email attachment link | 7 days (604800 s) |
| Webhook callback with file | 24 hours (86400 s) |
Never store signed URLs in your database. They expire and become invalid. Store only the file key; generate signed URLs on demand at serve time.
API route pattern
Expose a server-side endpoint that generates signed URLs after verifying the user's access rights.
// app/api/files/[key]/url/route.ts
import { getUploadProvider } from "@nebutra/uploads";
import { getCurrentTenant } from "@nebutra/tenant";
export async function GET(
_request: Request,
{ params }: { params: { key: string } }
) {
const tenant = getCurrentTenant();
const decodedKey = decodeURIComponent(params.key);
// Enforce tenant isolation: key must start with the tenant's prefix
if (!decodedKey.startsWith(`${tenant.tenantId}/`)) {
return Response.json({ error: "Forbidden" }, { status: 403 });
}
const uploads = await getUploadProvider();
const url = await uploads.getSignedUrl(
process.env.AWS_BUCKET_NAME!,
decodedKey,
{ expiresIn: 3600 }
);
return Response.json({ url, expiresIn: 3600 });
}Public files via CDN
For assets that should be publicly accessible (profile avatars, public logos, marketing images), configure your bucket for public access and serve files through a CDN.
Set R2_PUBLIC_URL to your custom domain or r2.dev subdomain. Public files are served without signing:
// Build a public URL directly — no API call needed
function getPublicUrl(key: string): string {
const baseUrl = process.env.R2_PUBLIC_URL!.replace(/\/$/, "");
return `${baseUrl}/${key}`;
}
const avatarUrl = getPublicUrl(`${tenantId}/avatars/user_123.jpg`);
// → https://assets.nebutra.com/org_acme/avatars/user_123.jpgCreate a CloudFront distribution pointing to your S3 bucket. Configure an Origin Access Control (OAC) policy so CloudFront can read from a private bucket.
function getPublicUrl(key: string): string {
const cloudfrontDomain = process.env.CLOUDFRONT_DOMAIN!;
return `https://${cloudfrontDomain}/${key}`;
}CloudFront signed URLs/cookies can gate access to CDN-served private files. This is more performant than S3 signed URLs for high-traffic scenarios because CloudFront caches at the edge.
Next.js Image optimization
Use next/image with your storage domain to get automatic resizing, format conversion (WebP/AVIF), and lazy loading.
// apps/landing/next.config.ts (or apps/web/next.config.ts)
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
// AWS S3
{
protocol: "https",
hostname: "nebutra-uploads.s3.us-east-1.amazonaws.com",
pathname: "/**",
},
// Cloudflare R2 custom domain
{
protocol: "https",
hostname: "assets.nebutra.com",
pathname: "/**",
},
],
},
};
export default nextConfig;import Image from "next/image";
interface AvatarProps {
fileKey: string;
signedUrl: string;
alt: string;
}
export function Avatar({ signedUrl, alt }: AvatarProps) {
return (
<Image
src={signedUrl}
alt={alt}
width={64}
height={64}
className="rounded-full object-cover"
/>
);
}Signed URLs contain query parameters that change on every generation. This prevents Next.js from caching the optimised image across requests. For frequently-displayed images (avatars, thumbnails), use public CDN URLs instead of signed URLs.
Listing files
Retrieve a paginated list of files within a tenant's namespace.
import { getUploadProvider } from "@nebutra/uploads";
const uploads = await getUploadProvider();
// List first page (1000 files max per request)
const { files, nextCursor } = await uploads.listFiles({
bucket: "nebutra-uploads",
prefix: `${tenantId}/docs/`,
limit: 50,
cursor: undefined, // pass nextCursor from previous response to paginate
});
// files → [{ key, size, lastModified, contentType }, ...]
// nextCursor → string | null (null when no more pages)Paginated listing in an API route
// app/api/files/route.ts
import { getUploadProvider } from "@nebutra/uploads";
import { getCurrentTenant } from "@nebutra/tenant";
import { z } from "zod";
const querySchema = z.object({
prefix: z.string().optional(),
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export async function GET(request: Request) {
const tenant = getCurrentTenant();
const { searchParams } = new URL(request.url);
const { prefix, cursor, limit } = querySchema.parse(
Object.fromEntries(searchParams)
);
const uploads = await getUploadProvider();
const result = await uploads.listFiles({
bucket: process.env.AWS_BUCKET_NAME!,
prefix: `${tenant.tenantId}/${prefix ?? ""}`,
limit,
cursor,
});
return Response.json(result);
}Deleting files
import { getUploadProvider } from "@nebutra/uploads";
const uploads = await getUploadProvider();
await uploads.deleteFile(
"nebutra-uploads",
`${tenantId}/docs/old-report.pdf`
);Always delete both the storage object and the database record in the same operation to avoid orphaned files.
await Promise.all([
uploads.deleteFile(bucket, attachment.key),
db.attachment.delete({ where: { id: attachment.id } }),
]);How is this guide?
Last updated on