Add file uploads with object storage

This guide adds file uploads to a Next.js app with Naijacloud object storage. At the end, files people upload are saved to a private bucket in af-west (Port Harcourt) or eu-west (Europe), and listed back with signed links that expire after an hour, using the standard AWS SDK for JavaScript against our S3-compatible endpoint. The stack is Next.js 16 and @aws-sdk/client-s3; the same calls work from any language with an S3 client.

20 minIntermediateLast verified 2026-10-07nextjss3
What you'll have at the end

An upload form in your app. Each file lands in a private bucket under uploads/, and the page lists them with links that only work for an hour.

https://<your-service>.naijacloud.app

Before you start

  • A Next.js app deployed as a web service on Naijacloud, from a GitHub repository. Deploy Next.js with Postgres and Prisma gets you one; the database isn’t needed here.
  • A Naijacloud account. Each workspace gets 5 GB of object storage free every month.

Create a private bucket

In the dashboard, open Object storage and click Create bucket. Give it a Bucket name (names are global, so make it yours, for example acme-uploads), choose the Region closest to your users and keep Access on Private. Click Create bucket.

Private means nothing in the bucket is readable without a signature. Your app hands out signed links instead, so you decide who sees each file.

Create an access key

Still in Object storage, open Access keys and click Create access key. Name it after the app that will use it, for example acme uploads, and click Create key.

Your new access key shows the Access Key ID and the Secret, and the endpoint for each region. Copy all three now: you won’t see the secret again.

Add the variables to your service

Open your web service, go to Variables and click Bulk edit. Add five lines, with your own values, then Save variables:

Variables
S3_ENDPOINT=https://af.storage.naijacloud.app
S3_REGION=af-west
S3_BUCKET=acme-uploads
S3_ACCESS_KEY_ID=your access key ID
S3_SECRET_ACCESS_KEY=your secret

Use the endpoint and region your bucket is in: https://af.storage.naijacloud.app with af-west, or https://eu.storage.naijacloud.app with eu-west. The key works in every region the workspace stores data in.

Install the SDK and create a client

Terminal
$ npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
lib/storage.ts
import { S3Client } from "@aws-sdk/client-s3";
export const bucket = process.env.S3_BUCKET!;
export const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION,
// Naijacloud buckets are addressed by path: https://<endpoint>/<bucket>/<key>
forcePathStyle: true,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
});

Receive uploads

A route handler takes the form post, writes the file to the bucket under a random prefix so names never collide, and sends the browser back to the list:

app/api/upload/route.ts
import { randomUUID } from "node:crypto";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { bucket, s3 } from "@/lib/storage";
export async function POST(request: Request) {
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File) || file.size === 0) {
return new Response("Choose a file first", { status: 400 });
}
await s3.send(
new PutObjectCommand({
Bucket: bucket,
Key: `uploads/${randomUUID()}-${file.name}`,
Body: Buffer.from(await file.arrayBuffer()),
ContentType: file.type || "application/octet-stream",
}),
);
// Back to the list. A relative Location keeps the public https address.
return new Response(null, { status: 303, headers: { Location: "/" } });
}

The page lists what’s in uploads/ and signs a one-hour link for each file. await connection() keeps this at request time, so building the app never needs the bucket.

app/page.tsx
import { GetObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { connection } from "next/server";
import { Suspense } from "react";
import { bucket, s3 } from "@/lib/storage";
async function Files() {
await connection();
const { Contents = [] } = await s3.send(
new ListObjectsV2Command({ Bucket: bucket, Prefix: "uploads/" }),
);
const files = await Promise.all(
Contents.map(async ({ Key }) => ({
key: Key!,
// A private bucket: each link is signed and expires after an hour.
url: await getSignedUrl(s3, new GetObjectCommand({ Bucket: bucket, Key }), {
expiresIn: 3600,
}),
})),
);
return (
<ul>
{files.map((f) => (
<li key={f.key}>
<a href={f.url}>{f.key.replace(/^uploads\/[0-9a-f-]{36}-/, "")}</a>
</li>
))}
</ul>
);
}
export default function Home() {
return (
<main>
<h1>Files</h1>
<form action="/api/upload" method="post" encType="multipart/form-data">
<input type="file" name="file" />
<button type="submit">Upload</button>
</form>
<Suspense fallback={<p>Loading…</p>}>
<Files />
</Suspense>
</main>
);
}

Deploy and upload a file

Terminal
$ git add .
$ git commit -m "Uploads to object storage"
$ git push

The push deploys. Open your app, choose a file and click Upload. It appears in the list; click it and the signed link opens the file. In the dashboard, the bucket’s page shows the same object under uploads/.

Whatever you build it with, it ships here.

Deploy your app for free

Free tier forever · No card