Uploads and frontend integration

# Secure file uploads in Next.js with Uppy and signed Transloadit Templates

Build a secure Next.js App Router upload with Uppy, server-only signing, a locked Template, authoritative validation, resumable transfer, and asynchronous completion.

Published August 24, 2026

## Key takeaways

* Keep the Transloadit Auth Secret in a `server-only` module and return only short-lived signed Assembly options.
* Authorize and rate-limit the signing Route Handler; possession of an application URL is not upload permission.
* Lock the saved Template, require signatures, and repeat client-side file restrictions at a trusted processing boundary.

A secure browser upload needs more than moving the Auth Secret into an API route. The server must authorize each signature request, the signed payload must name a constrained Template, the receiver must enforce file policy, and the application must reconcile processing after the user navigates away. This App Router design makes each responsibility explicit.

## In this guide

1. [Separate application authorization from upload execution](#secure-file-uploads-nextjs-uppy-section-1)
2. [Lock the Template before exposing the upload form](#secure-file-uploads-nextjs-uppy-section-2)
3. [Sign one short-lived request in a server-only module](#secure-file-uploads-nextjs-uppy-section-3)
4. [Protect the App Router signing endpoint](#secure-file-uploads-nextjs-uppy-section-4)
5. [Mount Uppy once inside a Client Component](#secure-file-uploads-nextjs-uppy-section-5)
6. [Reconcile progress, retries, and completion](#secure-file-uploads-nextjs-uppy-section-6)
7. [Test the controls as an attacker and as an interrupted user](#secure-file-uploads-nextjs-uppy-section-7)

## What matters most

* Create one Uppy instance for the client component and let the maintained Transloadit plugin coordinate resumable transfer.
* Treat browser progress as transfer progress and use verified notifications for durable processing completion.
* Store third-party storage access in least-privilege Template Credentials instead of request fields or client environment variables.

## Separate application authorization from upload execution

The browser is an untrusted caller even when the interface is part of your Next.js application. A Client Component may contain the public Auth Key and a Template ID, but it must never contain the Workspace Auth Secret, raw storage credentials, or authority to choose arbitrary processing Steps. Put the Auth Secret behind a Route Handler and make that endpoint decide whether the current application user may start this exact upload.

That decision is separate from Transloadit request integrity. A valid signature proves that your server approved the serialized Assembly parameters for a limited period; it does not prove that the person asking your server for a signature owns a project, remains within quota, passed a CSRF check, or may publish the result. The application authorization adapter must enforce those rules before signing. The saved Template then constrains what Transloadit will execute.

### Next.js

Application code authenticates the session, checks resource ownership and CSRF policy, applies rate or quota limits, and issues purpose-specific signed options.

### Uppy

Owns file selection, accessible upload UI, browser restrictions, progress, retries, and resumable transfer orchestration.

### Transloadit

Creates the Assembly, enforces the signed request and saved Template, inspects files, runs processing, and exports the configured results.

## Lock the Template before exposing the upload form

Create the processing workflow as a saved Template. Set `allow_steps_override` to `false` so a browser cannot submit replacement Steps alongside its `template_id`. Enable “Require signature auth” on this Template, or require correct signatures for the whole Workspace. These are distinct controls: the locked Template fixes the processing graph, while signature enforcement rejects altered, expired, or otherwise invalid signed parameters. Your application still decides which user may receive those parameters.

Repeat inexpensive interface restrictions at the processing boundary. The example caps every file at 50 MiB, caps an Assembly at five files, and uses `/file/filter` against detected MIME metadata rather than trusting the filename or browser declaration. Adapt the exact allowlist to the product. Add verification, malware scanning, quarantine, or human review when the content risk requires them; no single MIME check makes arbitrary user content safe.

The `user_uploads` value names Template Credentials stored in the Workspace. Give that external principal only the storage operations, bucket, and paths required by this workflow. The browser neither receives nor signs the underlying access key. Credential rotation can then happen independently of the Next.js bundle, but it must be coordinated because every Template that references the record is affected.

A locked Template with authoritative limits and scoped storage access

```
{
  "allow_steps_override": false,
  "auth": {
    "max_number_of_files": 5,
    "max_size": 52428800
  },
  "notify_url": "https://app.example.com/api/transloadit-notifications",
  "steps": {
    ":original": {
      "robot": "/upload/handle"
    },
    "accepted_images": {
      "use": ":original",
      "robot": "/file/filter",
      "accepts": [
        ["${file.mime}", "regex", "^(image/jpeg|image/png|image/webp)$"]
      ],
      "error_on_decline": true,
      "error_msg": "Only JPEG, PNG, and WebP images are accepted"
    },
    "stored": {
      "use": "accepted_images",
      "robot": "/s3/store",
      "credentials": "user_uploads"
    }
  }
}
```

## Sign one short-lived request in a `server-only` module

Keep server configuration in environment variables without a `NEXT_PUBLIC_` prefix and import `server-only` at the top of the signing module. Next.js will fail the build if client code imports that module. The marker is a useful guardrail, not a secret manager: production access policies, log redaction, preview-environment isolation, and credential rotation still matter.

The Node SDK adds the configured Auth Key, serializes the parameters, and returns that exact `params` string with its signature. Return both values unchanged. Parsing the string, adding a field, or serializing it again in a different order after signing produces a different payload and should be rejected. The example sets a five-minute expiry because Uppy requests options immediately before Assembly creation and adds a fresh nonce for each authorization.

Select the Template on the server. Do not accept `template_id`, `steps`, `notify_url`, export credentials, or unbounded transformation values from the request body and sign them blindly. If the product genuinely offers several upload workflows, map a small application-level operation such as `avatar` or `product-gallery` to an allowlisted Template and limits after checking the user’s permission.

A server-only helper that signs one fixed Template

```
import 'server-only'

import { randomUUID } from 'node:crypto'

import { Transloadit } from 'transloadit'

export interface AssemblyOptions {
  params: string
  signature: string
}

type TransloaditEnvironmentName =
  | 'TRANSLOADIT_KEY'
  | 'TRANSLOADIT_SECRET'
  | 'TRANSLOADIT_TEMPLATE_ID'

function readServerEnvironment(name: TransloaditEnvironmentName): string {
  const value = process.env[name]
  if (value == null || value === '') {
    throw new Error('Missing Transloadit server configuration')
  }

  return value
}

const templateId = readServerEnvironment('TRANSLOADIT_TEMPLATE_ID')
const transloadit = new Transloadit({
  authKey: readServerEnvironment('TRANSLOADIT_KEY'),
  authSecret: readServerEnvironment('TRANSLOADIT_SECRET'),
})

export function createAssemblyOptions(): AssemblyOptions {
  const requestParameters = {
    auth: {
      expires: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
      nonce: randomUUID(),
    },
    template_id: templateId,
  }

  return transloadit.calcSignature(requestParameters)
}
```

## Protect the App Router signing endpoint

A Route Handler is reachable like any other HTTP endpoint. The `authorizeUpload` function in the sample is deliberately application-specific: connect it to the project’s existing session library, resource ownership checks, CSRF strategy, and shared rate limiter. Return denial before signature generation. For multi-tenant applications, key limits by both tenant and user, and check the tenant that will own the eventual asset.

The route accepts no arbitrary Assembly parameters and marks the successful response `no-store`. Its public failure is intentionally generic. Log an internal request or trace identifier, decision category, and actor ID on the server, but do not return stack traces, account identifiers, third-party response bodies, or credential details to the browser. Let unexpected failures use the application’s centralized sanitized error handling rather than wrapping every call in a noisy catch block.

Rate limiting the signature route controls Assembly creation, but it does not replace account bill limits, Template file limits, or application quotas. Apply all three. A signature is a short-lived capability: anyone who obtains the complete signed payload can attempt to submit it while it remains valid, so send it only over HTTPS and avoid analytics, browser storage, URLs, and logs that retain it.

An authenticated App Router signing endpoint

```
import type { NextRequest } from 'next/server'

import { NextResponse } from 'next/server'

import { authorizeUpload } from '../../../server/upload-authorization'
import {
  type AssemblyOptions,
  createAssemblyOptions,
} from '../../../server/transloadit-options'

interface ErrorResponse {
  error: string
}

export async function POST(
  request: NextRequest,
): Promise<NextResponse<AssemblyOptions | ErrorResponse>> {
  const permission = await authorizeUpload(request)
  if (!permission.allowed) {
    return NextResponse.json({ error: 'Upload not allowed' }, { status: 403 })
  }

  return NextResponse.json(createAssemblyOptions(), {
    headers: { 'Cache-Control': 'no-store' },
  })
}
```

### Authenticate

Resolve a fresh server-side session instead of trusting a client-supplied user or tenant identifier.

### Authorize

Check that the actor may upload for the target resource and operation before creating the signed capability.

### Limit

Enforce per-user and per-tenant rates, concurrent work, storage policy, and business quotas in addition to Template limits.

## Mount Uppy once inside a Client Component

Uppy needs browser APIs, so the uploader lives behind a `use client` boundary. Create the Uppy instance once with a lazy state initializer. Recreating it during render discards selected files and breaks lifecycle ownership. This component destroys the instance when it owns the complete upload lifetime; if uploads must survive navigation between routes, lift the instance to a longer-lived client provider and destroy it when that provider ends.

The asynchronous `assemblyOptions` function calls the protected route as Uppy prepares the upload. It checks `response.ok`, safely handles malformed JSON, validates the response shape, and exposes only a stable user-facing authorization error. Uppy’s Dashboard supplies selection, progress, cancellation, and error UI, while the Transloadit plugin creates the Assembly and sends files through resumable upload infrastructure. The bounded retry schedule helps temporary failures without retrying forever.

Those retries operate while this Uppy instance remains alive. They do not make the shown component recover selected files and Assembly state after a page reload. If reload recovery is a product requirement, configure and test Golden Retriever or another documented persistence design with the Transloadit plugin; do not infer it from tus or `retryDelays` alone.

The browser limits mirror the Template for fast feedback, but they are not the security boundary. A caller can bypass the component, and file metadata can be false. Keep the authoritative file count, byte size, detected-content checks, and export policy in the locked Template. Also decide whether canceling the browser should cancel only transfer, the Assembly, or the application asset record, then test that decision instead of assuming all three states are identical.

A Client Component with aligned restrictions and bounded retries

```
'use client'

import Uppy from '@uppy/core'
import Dashboard from '@uppy/react/dashboard'
import Transloadit from '@uppy/transloadit'
import { type ReactNode, useEffect, useState } from 'react'
import { z } from 'zod'

import '@uppy/core/css/style.min.css'
import '@uppy/dashboard/css/style.min.css'

const assemblyOptionsSchema = z.object({
  params: z.string().min(1),
  signature: z.string().startsWith('sha384:'),
})

async function fetchAssemblyOptions(): Promise<z.infer<typeof assemblyOptionsSchema>> {
  const response = await fetch('/api/transloadit-params', {
    method: 'POST',
    headers: { Accept: 'application/json' },
  })

  if (!response.ok) {
    throw new Error('Could not authorize this upload. Try again.')
  }

  const responseBody: unknown = await response.json().catch(() => null)
  const parsedOptions = assemblyOptionsSchema.safeParse(responseBody)
  if (!parsedOptions.success) {
    throw new Error('Could not authorize this upload. Try again.')
  }

  return parsedOptions.data
}

function createUppy(): Uppy {
  return new Uppy({
    restrictions: {
      allowedFileTypes: ['image/jpeg', 'image/png', 'image/webp'],
      maxFileSize: 50 * 1024 * 1024,
      maxNumberOfFiles: 5,
    },
  }).use(Transloadit, {
    assemblyOptions: fetchAssemblyOptions,
    retryDelays: [0, 1_000, 3_000, 5_000],
    waitForEncoding: false,
  })
}

export function UploadForm(): ReactNode {
  const [uppy] = useState(createUppy)

  useEffect(() => {
    return () => uppy.destroy()
  }, [uppy])

  return <Dashboard height={420} proudlyDisplayPoweredByUppy={false} uppy={uppy} />
}
```

## Reconcile progress, retries, and completion

Transfer progress and processing progress answer different questions. With `waitForEncoding: false`, the interface can finish after bytes reach Transloadit while validation, transformation, and export continue. Listen for `transloadit:assembly-created` and associate the Assembly ID with a pending application record. A navigation can then interrupt browser observation without losing the identity needed for reconciliation.

Put a fixed `notify_url` in the server-owned Template for asynchronous completion. The notification handler must verify its signature using the Auth Secret associated with the Assembly’s Auth Key, validate the payload, match the Assembly ID to the expected pending record, and apply results idempotently before returning success. Notifications may be retried, so a duplicate must confirm the existing terminal state rather than create another asset or publication event.

Use `waitForEncoding: true` only for short workflows where the user should stay on the page and browser code genuinely needs final results. Even then, retain a server-side recovery path because tabs close and connections disappear. A scheduled reconciliation job can query nonterminal Assemblies whose notifications were missed. Separate retry policy by failure: resume network interruption, request new signed options when they expire before Assembly creation, and stop on a policy rejection until the user changes the file.

### Selected

The browser has a candidate file; no trusted system has accepted it yet.

### Uploaded

The receiver has the bytes, but authoritative validation, processing, or export may still fail.

### Ready

A verified terminal result has been persisted and is authorized for its intended application use.

## Test the controls as an attacker and as an interrupted user

Test the signing endpoint without a session, with the wrong tenant, with a missing or invalid CSRF token where applicable, above its rate limit, and after permission revocation. Confirm that no response or log contains the Auth Secret, Template Credentials, stack trace, or raw dependency error. Try replacing the `template_id`, adding `steps`, extending `auth.expires`, and submitting signed options after their expiry; signature or Template enforcement should reject the invalid request.

Exercise a permitted JPEG, a disallowed MIME type with an image extension, an oversized file, too many files, a zero-byte file, a connection loss at several offsets, browser reload, cancellation, expired authorization, duplicate notification, storage denial, and processing failure after upload. Confirm that reload behavior matches the persistence design instead of assuming automatic recovery. Verify accessible progress and error announcements with keyboard and assistive technology, then inspect temporary storage and application records for leaks or permanently pending states.

Monitor signature denials, rate-limit decisions, Assembly creation and failure rates, upload recovery, processing latency, notification age, storage errors, and pending-record age without logging protected payloads. Alert on sustained changes rather than individual user mistakes. Keep the Template ID and an application-managed workflow release in operational records because a saved Template can change over time.

## Technical details worth knowing

* An App Router `route.ts` file is an HTTP endpoint, so it must perform its own authentication, authorization, abuse controls, and input validation before returning a signature.
* The `server-only` package marker causes a build-time error if a protected module is imported into a Client Component, but deployment secrets still need correct platform configuration and access controls.
* The Transloadit Node SDK’s `calcSignature` method adds the Auth Key when configured, serializes the request parameters, and returns that exact `params` string with its HMAC signature.
* Signature Authentication covers `auth.expires` and the rest of the serialized request payload; changing a protected value after signing invalidates the signature.
* A Template may accept runtime Step overrides by default, so browser-owned workflows should set `allow_steps_override` to `false` unless a narrowly reviewed override is intentional.
* Uppy restrictions provide immediate browser feedback, while Template `auth.max_size`, `auth.max_number_of_files`, and file-processing Steps enforce policy after browser code can no longer be trusted.
* The Transloadit plugin accepts an asynchronous `assemblyOptions` function, creates an Assembly, and configures resumable uploads to the Assembly’s tus endpoint.
* With `waitForEncoding: false`, Uppy completes after transfer rather than after processing; the application should retain the Assembly ID and use a signature-verified notification or later status lookup for the terminal result.
* The bounded `retryDelays` sample resumes transient transfer failures while its Uppy instance remains alive; recovery after a reload requires persisted Uppy and Transloadit state, such as a deliberately configured Golden Retriever integration.
* Template Credentials are Workspace-side records referenced by name, so raw storage secrets do not appear in the saved Template JSON, client bundle, or signed Assembly parameters; the Template contains only the credential record name.

## A practical approach

1. 1\
   Create a locked, signature-required Template with upload limits, detected-content checks, and scoped Template Credentials.
2. 2\
   Add a server-only signing helper and an authenticated, rate-limited, non-cacheable App Router endpoint.
3. 3\
   Mount one Uppy instance in a Client Component with aligned restrictions, retries, and sanitized authorization failures.
4. 4\
   Persist the Assembly ID, verify completion notifications, and test denial, interruption, expiry, replay, and duplicate delivery.

A four-stage media workflow

## When Transloadit is useful

Use Uppy’s maintained Transloadit plugin when a Next.js browser flow needs resumable uploads followed by managed validation, transformation, and export. A saved Template fixes the permitted workflow, Signature Authentication protects the approved request parameters and expiry, and Template Credentials keep raw storage secrets out of both Next.js client bundles and Assembly parameters.

## Architecture boundary

Your Next.js application authenticates the user and decides whether to issue short-lived upload authorization. Uppy owns browser selection, progress, and transfer orchestration. Transloadit receives the bytes, applies the configured validation and processing Template, and reports results. None of those layers replaces the application’s durable asset record or publication policy.

## Frequently asked questions

### Can a Next.js Client Component contain the Transloadit Auth Key?

The Auth Key identifies the Workspace and can appear in a signed request, but the Auth Secret must remain server-side. Still require signatures and constrain the Template, because an exposed Auth Key without those controls can enable unauthorized requests.

### Why use a Route Handler instead of signing in a Server Component?

Uppy requests fresh Assembly options from browser code immediately before upload. A Route Handler provides that HTTP boundary, but it must authenticate and authorize the request just like any other mutation endpoint. A Server Function could implement a similar boundary if the integration calls it safely.

### Does a signed request for a locked Template prevent every kind of upload abuse?

No. It protects request integrity and constrains the processing recipe. You still need application authorization, rate and bill limits, file count and byte limits, detected-content validation, least-privilege storage, and any scanning or review required by the product’s threat model.

### Should `waitForEncoding` be true in Next.js?

Usually not for long work. With false, the user waits for transfer and the application completes processing through a verified notification. Set it to true only when the workflow is short and browser code needs final results, while retaining server-side reconciliation for closed tabs and lost connections.

### Where should S3 or other storage credentials live?

Store them as least-privilege Transloadit Template Credentials and reference the record by name from the saved Template. Do not put raw provider credentials in Next.js public environment variables, client code, signed request fields, logs, or result metadata.

### Are Uppy file restrictions enough to validate uploads?

No. They improve feedback for cooperative users. Repeat byte and count limits in the Template and inspect detected file properties with trusted processing Steps, because callers can bypass browser JavaScript and declarations can be misleading.

## Build the workflow

Move from the concept to a tested Assembly with Robot documentation and working demos.

* [Read the API documentation](/docs.md)
* [Explore working demos](/demos.md)
* [Create a free workspace](/c/signup/)

Uploads and frontend integration

## Continue with related guides

* [Best JavaScript file upload libraries: Uppy vs FilePond vs Dropzone](/guides/best-javascript-file-upload-libraries.md)\
  Compare Uppy, FilePond, and Dropzone by transfer protocol, interface model, recovery behavior, integration work, and long-term ownership.
* [How to accept large uploads that survive a dropped connection](/guides/resumable-uploads-for-large-files.md)\
  Accept multi-gigabyte uploads over tus, continue them after a dropped connection, and keep the Assembly alive long enough to finish.
* [File Upload API guide: architecture, security, and provider selection](/guides/file-upload-api-guide.md)\
  Choose and implement a File Upload API by comparing architecture, resumability, direct-to-cloud transfer, security, storage boundaries, and providers.
* [HTML video in production: 10 practical checks](/guides/html-video-production-checklist.md)\
  Ten production checks for HTML video, from source selection and captions to poster images and preprocessing.
* [Three best practices for CSS banner images](/guides/css-banner-image-best-practices.md)\
  Three durable practices for sharp, responsive CSS banner images without unreadable text or accidental crops.
* [Five reliable ways to center an image in HTML](/guides/center-images-in-html.md)\
  Five predictable ways to center images in HTML and how to choose between layout and media preprocessing.
