ScreenshotNeo

BlogHow-to

How to Use Cloudinary’s Image and Video API with Astro

Build an Astro upload flow with Cloudinary, keep credentials server-side, and deliver transformed images and videos. Includes working code, security, and troubleshooting.

By the ScreenshotNeo team4 October 202611 min read

Direct answer: handle uploads in an Astro server endpoint or server-rendered page, send the file to Cloudinary from that server with Cloudinary’s Node.js SDK, and render the returned asset URL. Keep the Cloudinary API secret on the server. Build image and video delivery URLs with the transformations your page needs. This guide uses a server endpoint and an HTML multipart form.

Cloudinary’s Astro upload tutorial follows this pattern: configure server-side Astro output, parse a multipart form, upload the file with upload_stream, and use the response to render a preview. A static-only Astro page cannot process this upload itself; deploy with a server-capable adapter or send the form to a separate server endpoint.

1. Configure Astro and Cloudinary

Start with an Astro project and add the Cloudinary Node SDK:

npm install cloudinary

For a server-rendered site, configure Astro’s output for server execution. For example, in astro.config.mjs:

import { defineConfig } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  output: 'server',
  adapter: node({ mode: 'standalone' }),
});

Install the adapter if you use this example:

npx astro add node

Choose the adapter that matches your hosting platform. The important requirement is a server runtime for the upload route. Astro can also use hybrid output when most pages are static and only selected routes need server execution.

Set credentials as server environment variables, for example in a local .env file excluded from version control:

CLOUDINARY_CLOUD_NAME=your_cloud_name
CLOUDINARY_API_KEY=your_api_key
CLOUDINARY_API_SECRET=your_api_secret

Never prefix the secret with PUBLIC_, put it in browser JavaScript, or commit it. Cloudinary’s upload endpoint and authentication options are documented in its upload documentation.

2. Add a server-side upload endpoint

Create src/pages/api/upload.ts. This endpoint accepts one image or video in a multipart field named file, checks basic limits, then streams its bytes to Cloudinary without writing them to disk.

import type { APIRoute } from 'astro';
import { v2 as cloudinary } from 'cloudinary';

cloudinary.config({
  cloud_name: import.meta.env.CLOUDINARY_CLOUD_NAME,
  api_key: import.meta.env.CLOUDINARY_API_KEY,
  api_secret: import.meta.env.CLOUDINARY_API_SECRET,
  secure: true,
});

const MAX_BYTES = 10 * 1024 * 1024;
const ALLOWED_TYPES = new Set([
  'image/jpeg', 'image/png', 'image/webp', 'image/gif',
  'video/mp4', 'video/webm', 'video/quicktime',
]);

function uploadBuffer(buffer: Buffer, resourceType: 'image' | 'video') {
  return new Promise<any>((resolve, reject) => {
    const stream = cloudinary.uploader.upload_stream(
      { resource_type: resourceType },
      (error, result) => {
        if (error) reject(error);
        else if (!result) reject(new Error('Cloudinary returned no upload result'));
        else resolve(result);
      },
    );
    stream.end(buffer);
  });
}

export const POST: APIRoute = async ({ request }) => {
  let form: FormData;
  try {
    form = await request.formData();
  } catch {
    return new Response(JSON.stringify({ error: 'Expected multipart form data' }), {
      status: 400, headers: { 'Content-Type': 'application/json' },
    });
  }

  const value = form.get('file');
  if (!(value instanceof File) || value.size === 0) {
    return Response.json({ error: 'Choose a non-empty file in the file field' }, { status: 400 });
  }
  if (value.size > MAX_BYTES) {
    return Response.json({ error: 'File exceeds the 10 MiB application limit' }, { status: 413 });
  }
  if (!ALLOWED_TYPES.has(value.type)) {
    return Response.json({ error: 'Unsupported file type' }, { status: 415 });
  }

  const resourceType = value.type.startsWith('video/') ? 'video' : 'image';
  try {
    const bytes = Buffer.from(await value.arrayBuffer());
    const asset = await uploadBuffer(bytes, resourceType);
    return Response.json({
      public_id: asset.public_id,
      resource_type: asset.resource_type,
      format: asset.format,
      secure_url: asset.secure_url,
      width: asset.width,
      height: asset.height,
      duration: asset.duration,
    });
  } catch (error) {
    console.error('Cloudinary upload failed', error);
    return Response.json({ error: 'Upload failed' }, { status: 502 });
  }
};

In a TypeScript project where the Cloudinary SDK type does not accept the callback result exactly as shown, replace any with the result type from the installed SDK version. The route intentionally returns only selected response fields; do not send API credentials or internal error details to the browser.

The file type and size checks here are application limits, not a complete security boundary. A browser can supply a misleading MIME type. For sensitive or public upload flows, also inspect file signatures, add authentication and rate limits, consider malware scanning, and apply storage restrictions appropriate to the app. Tune the size limit to the application and Cloudinary account limits.

3. Submit a multipart form and show the result

Create src/pages/upload.astro. The browser submits bytes to the Astro route; the server response contains a Cloudinary delivery URL.

---
const result = Astro.url.searchParams.get('result');
---
<html lang="en">
  <head><meta charset="utf-8" /><title>Upload media</title></head>
  <body>
    <h1>Upload an image or video</h1>
    <form id="upload-form" enctype="multipart/form-data">
      <label>Media file <input name="file" type="file" accept="image/*,video/*" required /></label>
      <button type="submit">Upload</button>
    </form>
    <p id="status" role="status"></p>
    <div id="preview"></div>
    <script>
      const form = document.querySelector('#upload-form');
      const status = document.querySelector('#status');
      const preview = document.querySelector('#preview');
      form?.addEventListener('submit', async (event) => {
        event.preventDefault();
        status.textContent = 'Uploading…';
        preview.replaceChildren();
        try {
          const response = await fetch('/api/upload', { method: 'POST', body: new FormData(form) });
          const data = await response.json();
          if (!response.ok) throw new Error(data.error || `Upload failed (${response.status})`);
          status.textContent = `Uploaded ${data.public_id}`;
          if (data.resource_type === 'video') {
            const video = document.createElement('video');
            video.src = data.secure_url;
            video.controls = true;
            video.width = 640;
            preview.append(video);
          } else {
            const image = document.createElement('img');
            image.src = data.secure_url;
            image.alt = 'Uploaded image';
            image.width = 640;
            preview.append(image);
          }
        } catch (error) {
          status.textContent = error instanceof Error ? error.message : 'Upload failed';
        }
      });
    </script>
  </body>
</html>

Use a normal form action and server-rendered response if you prefer a no-JavaScript flow. The fetch version keeps the page in place. For production, display progress for large uploads and avoid rendering untrusted HTML from metadata.

4. Upload through cURL, Python, or Node.js

The Astro route above is the recommended starting point when Astro owns the upload flow. These examples call Cloudinary directly using an unsigned upload preset. Create and configure that preset in Cloudinary first. Unsigned uploads are restricted; do not treat a preset name as a secret or use this approach without configuring appropriate format, size, and folder restrictions. For authenticated server uploads, use the SDK route above or implement Cloudinary’s documented signature scheme; never expose the API secret to a client.

cURL

curl -X POST "https://api.cloudinary.com/v1_1/YOUR_CLOUD_NAME/image/upload" \
  -F "file=@./photo.jpg" \
  -F "upload_preset=YOUR_UNSIGNED_PRESET"

Python

import requests

url = "https://api.cloudinary.com/v1_1/YOUR_CLOUD_NAME/image/upload"
with open("photo.jpg", "rb") as media:
    response = requests.post(
        url,
        data={"upload_preset": "YOUR_UNSIGNED_PRESET"},
        files={"file": media},
        timeout=90,
    )
response.raise_for_status()
asset = response.json()
print(asset["secure_url"])
print(asset["public_id"])

Node.js

const form = new FormData();
form.append('file', new Blob([await (await import('node:fs/promises')).readFile('./photo.jpg')]), 'photo.jpg');
form.append('upload_preset', 'YOUR_UNSIGNED_PRESET');

const response = await fetch(
  'https://api.cloudinary.com/v1_1/YOUR_CLOUD_NAME/image/upload',
  { method: 'POST', body: form },
);
if (!response.ok) throw new Error(`Upload failed: ${response.status} ${await response.text()}`);
const asset = await response.json();
console.log(asset.secure_url, asset.public_id);

For a video, change the endpoint segment from /image/upload to /video/upload. Cloudinary’s REST pattern is https://api.cloudinary.com/v1_1/<cloud name>/<resource_type>/upload; the supported resource types include image, video, raw, and auto. Uploads complete synchronously, and the returned identifiers can be used for delivery. See Cloudinary’s upload reference.

5. Deliver and transform images

A delivery URL identifies the cloud, asset type, delivery type, optional transformation, optional version, and public ID. For example, given cloud name demo and public ID sample:

https://res.cloudinary.com/demo/image/upload/c_fill,w_800,h_500,q_auto,f_auto/sample.jpg

Here c_fill fills the requested dimensions by cropping, w_800,h_500 set the output dimensions, and q_auto,f_auto request automatic quality and format selection. Use transformations that suit the layout: a product thumbnail may need a fixed crop, while an article image may need to preserve its aspect ratio. Cloudinary’s image transformation guide catalogs resizing, cropping, effects, overlays, and other transformation types.

For responsive images, generate appropriate width variants or use the Astro integration pattern from Cloudinary’s tutorial, which uses unpic for on-the-fly preview resizing and format conversion. Include dimensions or aspect ratio to reduce layout shifts, and provide useful alt text. Avoid changing a public ID in a way that makes application records point to the wrong asset.

6. Deliver and transform videos

Video delivery uses video in the asset-type position. A transformed URL can resize or crop a video, adjust quality or format, and apply other video transformations:

https://res.cloudinary.com/demo/video/upload/c_fill,w_960,h_540,q_auto/sample.mp4

Use an ordinary HTML video element when a simple file with controls is enough:

<video controls preload="metadata" width="960" poster="VIDEO_POSTER_URL">
  <source src="VIDEO_DELIVERY_URL" type="video/mp4" />
  Your browser does not support embedded video.
</video>

For adaptive streaming, specialized playback controls, or more advanced delivery, review Cloudinary’s video transformations and delivery guide and JavaScript video documentation. A video player is optional; it is not required for upload or basic delivery.

7. Choose the right upload and access model

Pattern Best fit Trust boundary
Astro server endpoint with SDK Application-controlled uploads and server-side validation Secret remains on server; application receives file and enforces its own policy
Browser upload with unsigned preset Direct-to-Cloudinary flow where preset restrictions are acceptable Browser does not need API secret; preset and upload limits must be configured for public exposure
Signed browser upload Direct uploads where server authorizes parameters Backend issues signature; secret stays server-side

Cloudinary’s default upload delivery type is generally public. Private delivery requires a signed URL for the original, while transformed versions may be publicly accessible unless strict transformations are configured. Authenticated delivery requires a signed URL or authentication token for originals and transformed versions. Choose deliberately for user-generated or confidential content; see Cloudinary’s delivery type documentation.

Store stable identifiers such as public_id, resource_type, and, where needed, delivery type in your database. Do not assume every uploaded asset is an image: video and raw assets use different delivery paths. Treat a URL as public if its delivery configuration allows unauthenticated access.

8. Reliability, performance, and cost considerations

  • Server memory: this example reads the whole file into memory. Limit upload size and concurrent requests. For large media, use a streaming or direct upload design that suits your runtime and account limits.
  • Timeouts and retries: large uploads can take longer than ordinary requests. Configure hosting request limits accordingly. Retry transient failures with a bounded strategy; avoid blind retries that create duplicate assets. Use a stable public ID or your own upload record to reconcile retries.
  • Derived delivery: Cloudinary generates transformed derivatives on first access and caches them on its CDN for later requests. Reuse consistent transformation URLs to benefit from that behavior; avoid generating many near-duplicate variants without a product need. See the image transformation documentation and video delivery guide.
  • Responsive output: serve dimensions suited to the rendered slot, and use automatic quality and format when appropriate. This reduces unnecessary transfer while preserving a suitable representation.
  • Cost: no fixed price or usage estimate is stated here because the supplied technical sources do not establish one. Review your Cloudinary account’s current plan and usage terms, and monitor storage, transformations, bandwidth, and upload volume against your own workload.

9. Troubleshooting

Symptom Likely cause Fix
Route returns 404 or Astro renders a static page Site is deployed as static-only or route is not in the expected location Use an Astro server-capable adapter/output and deploy the server endpoint.
Cloudinary reports missing or invalid credentials Environment variables are unset, misspelled, or unavailable to the deployed server Set server environment values in the host configuration; restart/redeploy and confirm the route reads the intended names.
Browser reports a CORS error Browser is calling Cloudinary directly or a different origin without matching configuration For the server endpoint example, send the form to the same-origin Astro route. If using direct upload, configure that architecture and its allowed origins deliberately.
400 response or empty file Form is not multipart, the input name differs, or no file was selected Use enctype="multipart/form-data", name the field file, and send FormData without manually setting its content type.
Unsupported file or resource type error Endpoint resource type does not match media or application validation rejected it Use video/upload for video in REST calls, or choose image/video from validated server-side metadata. Check allowed formats and preset restrictions.
413, request timeout, or memory failure File exceeds app, host, or account limit; buffering raises memory pressure Reduce the upload size, raise compatible limits where appropriate, or use a streaming/direct upload design. Check every layer’s request limit.
Upload succeeds but image/video does not render Wrong delivery URL, private asset, mismatched asset type, or invalid transformation Use the returned secure_url to confirm the original works, then inspect the delivery type and transformation URL.
Private media returns unauthorized Asset requires signed delivery or authentication token Generate authorized delivery through a server-side flow; do not make confidential assets public to work around the error.
Preview is stale or variant looks unchanged Browser/CDN is serving a cached URL or the transformation URL did not change Verify the URL encodes the intended transformation and use a distinct, deterministic transformation when output should differ.

10. Or skip the browser setup

If your next task is capturing a rendered page as an image or PDF, ScreenshotNeo is a website screenshot API and MCP server. One GET request returns a screenshot or PDF, with options for full-page capture, selectors, viewport/device settings, and more. See the API documentation.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

Cookie banners, popups, and chat widgets are removed before the shot. Bot checks, blank pages, and failed loads are never billed; response headers report the page verdict and billing status. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.

FAQ

Does uploading an asset also resize it?

Upload stores the asset; transformations are applied when you request a transformed delivery URL. Build the URL for the rendered size and crop you need.

Can I use Cloudinary without an Astro server?

Yes, a configured unsigned preset can support direct browser uploads, or a separate backend can authorize signed uploads. A static page alone cannot safely perform a secret-authenticated upload.

Do I need Cloudinary’s video player?

No. A returned video URL can be used with a standard HTML <video> element. Use player-specific tooling only when your playback requirements call for it.

When is an uploaded asset available?

Cloudinary documents uploads as synchronous; after the upload completes, the asset is available for transformation and delivery.

Primary references