Sherman Web Design Docs: switch app
Open the studio

Site folder format for imports

A whole site can live as a folder of plain files on your computer or in a GitHub repository: templates, pages, flows, records and images. Zip the folder and import it into a new or existing site. This page describes exactly what goes in the folder.

Overview

  • Same format both ways. An export from the studio (a site's menu, Export) is a folder in this format, so the quickest start is to export a site and edit it. You can also write the folder by hand, or generate it with your own tools.
  • One file is required: export.json. It marks the site folder. Everything else is optional: include only what your site needs.
  • Nothing is deleted. An import only adds or updates. If something with the same name or address is already on the site, you choose to skip it, replace it, or keep both.
  • Check before you change anything. Every import starts with a summary that reads and checks every file and changes nothing (the dry run, below).
  • Never in a folder: secret values, the site's team, domains, mail and payments. Those are set up on the site itself.

The folder

my-site/
├── export.json                 required: marks the site folder
├── templates/                  Mustache templates (HTML, CSS, XML, JSON, JS, text)
│   ├── header.html             template "header", usable as {{> header}}
│   ├── page.html               template "page"
│   ├── page.draft.html         optional: an unpublished draft of "page"
│   └── blog/post.html          template "blog/post"
├── templates.json              optional: which file is each template's published version and draft
├── pages/                      one JSON file per page, redirect or file address (any file names, sub-folders allowed)
│   ├── index.json
│   └── about.json
├── flows/                      one JSON file per flow (forms, APIs, scheduled jobs)
│   └── contact.json
├── collections.json            optional: content collections (blogs, events) and record indexes
├── records/
│   └── posts/                  one folder per collection
│       └── posts.ndjson        one record per line
├── media/                      images and PDFs
│   └── logo.png
├── media.json                  optional: ids, alt text and names for the files in media/
├── settings.json               optional: sign-in levels, access groups, spam protection and other site settings
├── secrets.json                optional: the names of secrets your flows need (never their values)
├── form-entries/*.ndjson       optional: saved form entries
└── visitors/levels-*.ndjson    optional: visitors' sign-in levels and access groups
    visitors/groups-*.ndjson

Names are case-sensitive and use / between folders. Files the format doesn't use (a README.md, notes) are skipped, and the import summary lists them. Folders added by zip tools and git (__MACOSX, .git, .DS_Store) are ignored.

File by file

export.json (required)

{ "format": "opus26-site-export", "version": 1, "site": { "name": "Starter site" } }

format must be exactly opus26-site-export. version is 1 (it can be left out). site.name is shown in the import summary. Exports also write exported_at, options, counts and notes, which are for information only.

templates/ and templates.json

Each file in templates/ is a template, named after its path without the extension: templates/home.html is home, templates/blog/post.html is blog/post. A file ending in .draft.<ext> is that template's unpublished draft. The extension doesn't change how a template is served; the page that uses it does. Templates are published when imported, and drafts stay drafts.

{{> header}}
<h1>{{page.title}}</h1>
{{{page.body}}}
{{> footer}}

Without templates.json, every file in templates/ is imported. With it, only the files it lists are imported (an export always writes it):

{ "templates": [
  { "name": "home", "published": { "file": "templates/home.html" }, "draft": { "file": "templates/home.draft.html" } },
  { "name": "styles", "published": { "file": "templates/styles.css" }, "draft": null } ] }

pages/*.json

One file per address. The file name doesn't matter, but path does.

{ "path": "/about", "template": "page",
  "data": { "title": "About us", "body": "<p>Who we are.</p>" } }
FieldWhat it does
pathRequired. Starts with /, with no trailing slash: /, /about, /blog/:slug (one page per record), /docs/*.
kindpage (default), redirect or file.
templatePages: the template to render (required).
dataPages: a JSON object shown as {{page.*}}.
content_type, templatedNon-HTML pages: css, js, json, xml or text. These are sent exactly as written unless templated is true.
sourcePages that show records, such as a blog. See Blogs, events and other content.
requires_session, min_level, access_groups, login_pathMembers-only pages. See Sign-in levels.
data_flow, data_cache_secondsA GET flow whose answer is shown as {{flow.*}}.
sitemapoff keeps the page out of sitemap.xml.
redirect_toRedirects: where visitors go (required).
asset_idFile addresses: a file from media/, by its id or as /media/<id>.<ext> (required).
{ "path": "/news", "kind": "redirect", "redirect_to": "/blog" }
{ "path": "/styles.css", "template": "styles", "content_type": "css" }
{ "path": "/blog/:slug", "template": "post", "source": { "collection": "posts", "markdown": ["body"] } }

flows/<name>.json

One file per flow, directly inside flows/. The fields are the same as when you create a flow (see Flows). name uses lowercase letters, digits, _ and -, and the flow answers at /api/flows/<name>.

{ "name": "contact", "method": "POST",
  "steps": [ { "type": "store_submission", "fields": ["name", "email", "message"] },
             { "type": "respond", "redirect": "/thanks" } ] }

A flow can also carry a schedule ({ "cron": "0 9 * * 1", "timezone": "America/New_York" } or { "every_minutes": 60 }). On a different site it arrives paused, so you can resume it when the site is ready (see Scheduled flows).

collections.json and records/

Each .ndjson file in records/<collection>/ holds one record per line. The folder name is the collection: lowercase letters, digits, _ and -, starting with a letter or digit.

{"id":"hello-world","data":{"title":"Hello, world","date":"2026-10-01","body":"Our **first** post."}}

collections.json marks content collections (shown in the studio's Content tab) and adds record indexes:

{ "collections": [ { "collection": "posts", "content": true } ], "indexes": ["date"] }

media/ and media.json

Images and PDFs: PNG, JPEG, GIF, WebP, AVIF, ICO, SVG and PDF. SVGs with scripts or external references are refused. Without media.json, every file in media/ is imported and its id is its name without the extension. Templates and pages link to /media/logo.png, and the import updates those links to point at the site's own copy.

media.json lets you set alt text, the name shown in the studio, or keep a file unpublished:

{ "files": [ { "id": "logo", "file": "media/logo.png", "alt": "Our logo", "status": "published" } ] }

A file the site already has (the same bytes) is reused, not copied again.

settings.json and secrets.json

settings.json carries levels, access_groups, level_rules, group_rules, captcha, visitor_uploads, studio_signin, allowed_domains and site. The easiest way to get it right is to set these up on a site and export it. secrets.json lists only the names your flows use ({ "secrets": [ { "name": "API_KEY" } ] }), and the summary reminds you to add any that the receiving site doesn't have yet.

form-entries/ and visitors/

These are mainly for moving a live site. form-entries/*.ndjson has one saved entry per line ({"flow":"contact","data":{…},"created_at":"…"}). visitors/levels-*.ndjson and visitors/groups-*.ndjson give visitors' levels and groups ({"kind":"email","value":"ana@example.com","level":"member"}). Visitors sign in again on the receiving site.

Rules

  • Where the folder sits in the zip. The site folder is the one holding export.json. It can be at the top of the zip, inside one folder, or deeper, as in GitHub's Download ZIP of a repository (my-repo-main/sites/church/export.json). If there is more than one, the one nearest the top is used. Two at the same level are refused, so zip one site at a time.
  • Text files are UTF-8. JSON files contain one JSON object; .ndjson files contain one JSON object per line.
  • References. A page's template and {{> partial}} names refer to templates, /api/flows/<name> and data_flow refer to flows, and /media/<id>.<ext> and asset_id refer to files. If you choose keep both and something is renamed on import, these references follow the new names.
  • Sizes. A zip of any size can be imported, and so can files of any size (SVGs up to 32 MB). Each JSON or NDJSON file can be up to 64 MB, a record up to 64 KB, and a form entry up to 64 KB.
  • Who can import. Admins and Owners of the receiving site. Owner-only settings in an Admin's import are left for an Owner and listed in the result.

Check a folder (dry run)

Importing has two steps, and the first one is a dry run: it reads and checks every file and changes nothing. The summary shows:

  • what will be added, and what is already on the site
  • the settings it would set, and any secrets still to add
  • Problems found in the files, for example a page without a path, a template listed in templates.json that isn't in the zip, a file in media/ that media.json doesn't list, or a record line without an id

If you stop there, nothing on the site changes (the uploaded zip is removed after 7 days). Fix the files and import again. You can also go ahead, and the items with problems are skipped.

To check files before zipping them, for example in your repository's checks, use the JSON Schemas at studio.shermanwebdesign.com/schemas/site-export/v1/. The index lists one schema per file type (export, templates, page, flow, collections, record, media, settings, secrets, form-entry, visitor-level, visitor-group). Any JSON Schema (2020-12) validator works, and editors such as VS Code check a file as you type when it starts with "$schema":

{ "$schema": "https://studio.shermanwebdesign.com/schemas/site-export/v1/page.schema.json",
  "path": "/about", "template": "page" }

The import summary is the final word: it also checks things a schema can't, such as whether a file listed in media.json is actually in the zip.

Import it

  1. Zip the folder. On a Mac or Windows, right-click it and compress it. In a terminal, run cd my-site && zip -r ../my-site.zip .. From GitHub, use Code → Download ZIP.
  2. In the studio: open the site's menu, choose Import, pick the zip (or choose to create a new site from it), and read the summary.
  3. Or ask the assistant, in the studio or your own AI client: “Import my-site.zip into the Starter site”. It shows the same summary before anything changes.
  4. Choose what happens to items that already exist (skip, replace or keep both) and confirm. The result lists what was created, replaced, skipped and anything that failed.

Importing uses no credits. While the uploaded zip is kept (up to 7 days), it counts toward the site's file storage.

A minimal starter

This is the smallest useful site, a single page:

hello/
├── export.json        { "format": "opus26-site-export", "version": 1 }
├── templates/
│   └── home.html      <!doctype html><title>{{page.title}}</title><h1>{{page.title}}</h1>
└── pages/
    └── index.json     { "path": "/", "template": "home", "data": { "title": "Hello" } }

For a fuller start, download site-import-starter.zip. It has a home page, an about page, a blog whose posts are records, a contact form flow, a stylesheet, a logo and a redirect, and it imports with no problems. Unzip it, change what you like (or put it in your repository), zip it again and import it. Every JSON file in it starts with its "$schema", so your editor checks it as you type.

Updated October 28, 2026