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 caseRecommended expiry
Inline display (images, PDFs in browser)1 hour (3600 s)
Download link shown in UI15 minutes (900 s)
Email attachment link7 days (604800 s)
Webhook callback with file24 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.jpg

Create 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?

Edit on GitHub

Last updated on

On this page