Skip to content
RELKIT
Esc
↑↓navigate↵open⌘Jpreview
On this page

Upload product images

Accept two multipart image uploads, store them in a bucket, and verify the returned object keys.

Add POST /uploads to an existing application. You’ll send two images, store them in the assets bucket, and check the returned object keys.

Create the four files below. If your app already has an Assets service or an uploads route, merge these members into it. Run commands from your app’s root.

1. Configure storage

This local S3-compatible selection uses MinIO and requires a running Docker daemon. Install its adapter and Docker materializer from your app root:

From this guide’s application/package root, install only its selected requirements:

bun add @relkit/docker@0.7.12 @relkit/s3@0.7.12

Keep these owner-specific versions and the lockfile together. Runtime SDKs belong in dependencies; build/type tooling belongs in devDependencies. Installing an adapter does not supply its optional SDK peers or enable the feature’s configuration.

In relkit.config.ts, import s3 from @relkit/s3, docker from @relkit/docker, and env as binding from @relkit/app/config. Create a bucket object inside defineApp() if the app has none, then add this assets entry:

assets: docker(
  s3({
    endpoint: binding.url("ASSETS_S3_ENDPOINT"),
    bucketName: binding.string("ASSETS_S3_BUCKET"),
    region: binding.string("ASSETS_S3_REGION"),
    credentials: {
      accessKeyId: binding.secret("ASSETS_S3_ACCESS_KEY_ID"),
      secretAccessKey: binding.secret("ASSETS_S3_SECRET_ACCESS_KEY"),
    },
    forcePathStyle: true,
  }),
),

assets starts a local MinIO recipe in development. The complete commerce application also has other bucket profiles; this first upload needs only assets. Connected and cloud ownership choices are described in Define a bucket.

2. Define the bucket

/** Declares private JSON/image assets with a five-million-byte limit for validated upload handlers. */
import { defineBucket } from "@relkit/app/buckets";

const assets = defineBucket({
  id: "assets.objects",
  profile: "assets",
  visibility: "private",
  maxObjectBytes: 5_000_000,
  allowedContentTypes: ["application/json", "image/*"],
});

export default assets;

3. Add the upload function

The function validates the upload, saves each image, and returns the stored keys:

/** Validates multipart image files, writes them through the declared assets bucket and returns their uploaded names. */
import { defineFunction } from "@relkit/app/functions";
import { z } from "@relkit/app/schema";
import assets from "../buckets/assets.bucket.js";

const uploadedFile = z.file({
  maxBytes: 1024 * 1024 * 10,
  mediaTypes: ["image/jpeg", "image/png"],
});

const uploadAssets = defineFunction({
  input: z.object({
    label: z.string().min(1),
    primary: uploadedFile,
    attachments: z.array(uploadedFile),
  }),

  output: z.object({
    label: z.string(),
    files: z.array(z.string()),
  }),

  dependencies: { buckets: { assets } },

  handler: async ({ label, primary, attachments }, context) => {
    const files = [primary, ...attachments];

    // This demo uses filenames as keys; uploading the same name replaces the file.
    await Promise.all(
      files.map(async (file) =>
        context.buckets.assets.put(file.name, new Uint8Array(await file.arrayBuffer()), {
          contentType: file.type,
        }),
      ),
    );

    return { label, files: files.map(({ name }) => name) };
  },
});

export default uploadAssets;

For this local example, filenames are the keys. Reusing a filename replaces that object. Use server-generated keys and authorize requests before accepting real users’ uploads.

4. Expose the endpoint

Add the function to your Assets service:

/** Declares the assets service boundary without acquiring native clients. */
import { defineService } from "@relkit/app/services";
import uploadAssets from "./functions/upload-assets.function.js";

export default defineService({ functions: { uploadAssets } });

Connect it to POST /uploads:

/** Admits bounded multipart upload requests to the assets service's validated image handler. */
import { defineRoute } from "@relkit/app/routes";
import assets from "../../services/assets/service.js";

export const POST = defineRoute({
  target: assets.uploadAssets,
  accept: "multipart/form-data",
  maxBodyBytes: 10 * 1024 * 1024,
});

5. Upload two images

Check and start the app:

bun run check
bun run dev

The local MinIO recipe supplies generated binding-only credentials and loopback ports. Keep the app running and open a second terminal in the app’s root. bun run dev --local=off disables recipes and requires the named connected/infrastructure values instead.

Place two small PNG images named cover.png and detail.png there. Keep their combined size below 1 MB for this request, then send:

curl -i http://localhost:3000/uploads \
  -F label=book-photos \
  -F 'primary=@cover.png;type=image/png' \
  -F 'attachments=@detail.png;type=image/png'

Use your app’s configured port if it differs from 3000. Expect HTTP 200:

{ "label": "book-photos", "files": ["cover.png", "detail.png"] }

6. Check the stored files

Optionally complete the separate Inspector installation, bridge and tool setup. Open its UI, normally at http://localhost:3210. Select Buckets, open assets.objects, and look for cover.png and detail.png. Click Preview beside either key to view its image.

Tests must provide explicit replacements for profile assets; no environment name installs a file fake. You can now read the files or create download links. Before deploying, review ownership in Integrations and add authentication to the upload endpoint.

Read files and create download links, or add sign-in before accepting uploads from users.