---
title: Publish a multi-file site (and get relative paths right)
slug: multi-file-sites
description: A copy-pasteable guide to publishing complete static sites, preserving relative paths, staging versions, and rolling back without changing the URL.
pillar: tutorials
cta: A
date: 2026-09-07
status: published
---

A static site is rarely one HTML string. It is an `index.html`, a stylesheet, a script, images, maybe a custom `404.html`, and a small pile of paths that only work if the browser resolves them the same way you did locally. [OpenArtifacts](/) publishes that pile as one immutable version and gives the artifact one stable URL.

The important word is *one*. A publish is a complete snapshot, not a patch. If version two omits `css/app.css`, that file is not borrowed from version one.

## Build a boring directory

Start with this shape:

```
site/
  index.html
  404.html
  css/app.css
  js/app.js
  images/mark.svg
```

Inside `index.html`, link from the document to the asset:

```
<link rel="stylesheet" href="css/app.css">
<script type="module" src="js/app.js"></script>
<img src="images/mark.svg" alt="">
```

Use relative paths. In production the artifact has its own subdomain. In local development it is served under `/site/<host>/`. A path such as `css/app.css` works in both places. A leading slash such as `/css/app.css` escapes the artifact's directory in local development and asks the router itself for the file.

The same rule applies inside CSS. From `css/app.css`, an image in the sibling top-level `images` directory is `../images/mark.svg`, not `/images/mark.svg`.

## Publish every file in one request

Create a token if you do not have one:

```
curl -sX POST https://api.openartifacts.sh/v1/signup
```

Then send each file as a multipart part. The part's `filename` is its path inside the site:

```
curl -sX POST https://api.openartifacts.sh/v1/publish \
  -H "Authorization: Bearer $OA_TOKEN" \
  -F slug=field-notes \
  -F 'files=@site/index.html;filename=index.html' \
  -F 'files=@site/404.html;filename=404.html' \
  -F 'files=@site/css/app.css;filename=css/app.css' \
  -F 'files=@site/js/app.js;filename=js/app.js' \
  -F 'files=@site/images/mark.svg;filename=images/mark.svg'
```

The response names what actually landed:

```
{
  "url": "https://field-notes-k3p7.openartifactsusercontent.com",
  "slug": "field-notes",
  "version": 1,
  "activated": true,
  "files": ["404.html", "css/app.css", "images/mark.svg", "index.html", "js/app.js"],
  "total_bytes": 18421
}
```

Do not confuse a form field name with a path. In the documented multipart form, setting `filename=css/app.css` is what keeps the directory.

Paths must be relative and may not traverse with `..`. One publish may contain at most 50 files and 10 MB.

## Treat a version as a complete site

The public contract is simple: every publish creates a numbered version containing exactly the files in that request. Activating it changes what the stable URL serves. It does not merge the new upload into the previous version.

That gives an agent a useful rule: assemble and validate the whole output directory first, then publish it once. If an asset is intentionally removed, omit it. If an asset should remain, include it again. The publish response's sorted `files` list and `total_bytes` are a cheap deployment receipt; version detail also reports `file_count`. Compare them with what you meant to send before activating a staged version.

Visitors see one activated version at a time. The practical benefit is that HTML and its assets change together, while an older complete version remains available for rollback.

## Stage before changing the live pointer

Publishing the same slug through `/v1/publish` creates and activates the next version. For a deliberate staging step, post to the versions route with `activate=false`:

```
curl -sX POST \
  'https://api.openartifacts.sh/v1/artifacts/field-notes/versions?activate=false' \
  -H "Authorization: Bearer $OA_TOKEN" \
  -F 'files=@site/index.html;filename=index.html' \
  -F 'files=@site/css/app.css;filename=css/app.css' \
  -F 'files=@site/js/app.js;filename=js/app.js' \
  -F 'files=@site/images/mark.svg;filename=images/mark.svg' \
  -F 'files=@site/404.html;filename=404.html'
```

That records version 2 but leaves version 1 live. There is no public preview URL for an inactive version today; staging lets automation inspect the API result and version inventory before activation.

```
curl -s https://api.openartifacts.sh/v1/artifacts/field-notes \
  -H "Authorization: Bearer $OA_TOKEN"

curl -sX POST \
  https://api.openartifacts.sh/v1/artifacts/field-notes/activate \
  -H "Authorization: Bearer $OA_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"version":2}'
```

Activation uses the integer `version` from the version list. Rollback is the identical call with an earlier number:

```
curl -sX POST \
  https://api.openartifacts.sh/v1/artifacts/field-notes/activate \
  -H "Authorization: Bearer $OA_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"version":1}'
```

The hostname never changes. Only the selected version changes.

## Know the public serving rules

Extensionless links are supported: a request for `/about` can resolve to an uploaded `/about.html` or `/about/index.html`. A supplied `/404.html` is returned with an actual 404 status for unknown paths. Those conveniences do not fix broken asset references, which is why relative paths remain the important part of this tutorial.

This design is why OpenArtifacts is a better fit for agent-built static sites than a deployment flow that asks the agent to configure a build project, repository, and framework adapter. The artifact is the files. The API makes the snapshot boundary explicit, keeps version history, and rolls back with one call. If conversational tool use is a better fit than multipart curl, the same model is exposed through the [MCP server](/blog/mcp-server).

You can try all of this without a signup form or card. The provisional token works immediately; show its claim URL to a human within 30 minutes. Claiming keeps the stable URL and unlocks 100 free publishes each month.
