---
title: Media and images
description: Your bucket, your bytes. Public and private projects, and why a static build must ask for permanent URLs.
---

ZevContent does not host your images. Media lives in **your** object storage:
ZevCloud Storage, S3, R2, or anything S3-compatible. No byte of it passes
through us.

Uploads go browser → your bucket, using a URL we sign. Reads go browser → your
bucket, using a URL we resolve. We store a record of the asset, never the file.

> **INFO: Why it works this way**
>
> The alternative is proxying media through our CDN, which would make your
> bandwidth our cost and our outage your outage. Keeping bytes out of the path
> means an image is exactly as fast and exactly as available as your own
> storage, and your bill is your own.

## What an image looks like in a response

Every `image` field, gallery entry, featured image, avatar and og image is the
same object:

```json
{
  "url": "https://cdn.example.com/media/hero.jpg",
  "alt": "The team at the launch",
  "width": 1600,
  "height": 900,
  "content_type": "image/jpeg"
}
```

`width` and `height` are there so you can reserve space and avoid layout shift
without measuring the file. They are null when we could not determine them.

You never receive a raw `med_…` id in a content response. Resolution happens
server-side, in one batched pass per request, so an article with a dozen
pictures costs the same round trip as one without.

## Public and private projects

Set per project, in the dashboard, and it changes the shape of every image URL.

**Public bucket.** URLs are permanent. They keep working forever, they cache
well, and they are safe to bake into static HTML.

**Private bucket.** URLs are presigned `GET`s with an expiry, and the object
gains one extra key:

```json
{
  "url": "https://…?X-Amz-Signature=…",
  "url_expires_at": "2026-09-02T11:30:00.000Z",
  "…": "…"
}
```

The response also carries a short `Cache-Control`, because caching a URL past
its own expiry serves a broken image.

## Static builds: ask for permanent URLs

A static build bakes URLs into HTML that outlives the build. A presigned URL
dies hours later, and the failure appears long after the deploy that caused it,
on a page nobody was looking at.

So say what you need:

```bash
curl "https://api.zevcontent.net/v1/blog/articles?media_urls=permanent" \
  -H "Authorization: Bearer $ZEVCONTENT_KEY"
```

On a public project this changes nothing. On a private project the request is
**refused** with `permanent_media_urls_unavailable`, rather than handing you
links that will break:

> This project's media bucket is private, so every image URL is signed and
> expires, so there are no permanent URLs to give you. Either make the bucket
> publicly readable and switch the project to public media access in the
> dashboard, or fetch this content at request time and re-fetch before
> `url_expires_at`, or download each image at build time and serve it
> yourself.

That is a build failure instead of a silent breakage weeks later, which is the
whole reason the parameter exists.

> **WARNING: Private buckets and static site generators do not mix**
>
> If you are on Astro, Next `output: 'export'`, Hugo, Eleventy or any other SSG,
> either make the bucket public or download the images at build time. There is no
> third option that ends well.

## Knowing before you fetch

`/v1/introspection` reports the project's media policy once, next to the schema
that mentions it:

```json
"media": { "access": "private", "urls": "signed", "url_ttl_seconds": 3600 }
```

so a client does not have to infer it from the presence of a key inside one
field's shape.

## Images inside rich text

The same rules apply to `image` nodes in a rich text document, with one
addition: a node may reference an **external** URL instead of a library asset.
An external image never expires and is never fetched by us. See
[Rich text](/concepts/rich-text#images-inside-a-document).