# Add file uploads with object storage

Source: https://naijacloud.com/guides/file-uploads-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 min · Intermediate · Last verified 2026-10-07 · Stack: nextjs, s3_

**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.

## 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](https://naijacloud.com/guides/nextjs-postgres-prisma.md) 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**:

```text title="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

```sh
$ npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
```

```ts title="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!,
  },
});
```

> **Note: Set forcePathStyle**
>
> It puts the bucket name in the path (`https://af.storage.naijacloud.app/acme-uploads/…`) instead of the hostname, which is the form the endpoints and this guide use.

## 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:

```ts title="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: "/" } });
}
```

## List files with signed links

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.

```tsx title="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

```sh
$ 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/`.

> **Warning: Private stays private**
>
> Opening `https://af.storage.naijacloud.app/acme-uploads/uploads/…` without a signature returns 403. That’s the point: only links your app signs work, and only for as long as you chose.

## Reference

- [Object storage](https://naijacloud.com/docs/storage/overview.md)
- [The S3 API](https://naijacloud.com/docs/storage/s3-api.md)
- [Access and keys](https://naijacloud.com/docs/storage/access.md)
- [Environment variables](https://naijacloud.com/docs/deploy/environment-variables.md)

All guides: https://naijacloud.com/guides · Index for agents: https://naijacloud.com/llms.txt
