Static sites
A folder with sails.site.json at the mount root compiles as a content site when shipped. There is no build command. Pages compile on first HTTP request (JIT). Derivatives are cached by the page plus the glob collection files it reads. Query strings like search are not cached.
Use this for blogs, docs, landing pages, and portfolios. Forms and JSON APIs live in *.api.sh on the same mount. App rows use sql --db, not page globs.
Done looks like
cp -r /templates/static-blog ./site
ship create site --policy 644
ship list
Open public_url. / is the homepage. /blog/hello is HTML from pages/blog/hello.page.md. /blog/hello.md is the markdown twin for agents. Edit a page under site/pages/ and stop — autosave updates the same URL. You did not run ship create again.
Worked example
cp -r /templates/static-site-demo ./site
# or: personal-site, product-landing, saas-marketing, static-blog, static-site-kit
ship create site --policy 644
Expected create JSON shape (not exact ids):
{
"name": "site",
"path": "site",
"policy": "644",
"public_url": "https://{id}.sails.app/"
}
| Request | Source |
|---|---|
/ or /index.html | pages/index.page.html |
/blog/hello | pages/blog/hello.page.md (compiled HTML) |
/blog/hello.md | Markdown twin of that page |
/styles.css | public/styles.css |
Markdown for agents: prefer GET /blog/hello.md, or Accept: text/markdown on the HTML URL. HTML responses also advertise an alternate markdown Link header. Source paths (pages/, components/, sails.site.json) are not served publicly.
Layout
my-site/
sails.site.json # required — enables static site mode
pages/ # .page.html, .page.md, .html, *.api.sh
components/ # optional — *.component.html layouts
public/ # static assets at site root (optional)
Components may live anywhere under the site root except public/, not only in components/.
sails.site.json defaults:
{
"pages": "pages",
"output": "dist",
"public": "public"
}
| Field | Default | Purpose |
|---|---|---|
cacheControl | no-cache | Cache-Control for pages and assets. Default requires ETag revalidation. Example: public, max-age=3600 |
notFoundPage | (none) | Page stem (e.g. 404) returned with HTTP 404 for missing page-like URLs. Browser URL stays the request path. Missing assets still plain 404 |
spaFallbackPage | (none) | Same resolution, returned with HTTP 200 for client routers (e.g. index). Takes precedence over notFoundPage. Use for Remix/SPA shells |
sails.site.json must sit at the directory passed to ship create, not a nested folder inside the mount.
File extensions
| Role | Extension | Published? |
|---|---|---|
| HTML page | .page.html | → .html |
| Markdown page | .page.md | → .html |
| Server handler | .api.sh | hidden |
| Component | .component.html | hidden |
| Plain HTML | .html | → .html (no markdown step) |
Legacy aliases still work: .shtml → .page.html, .smd → .page.md, _components/*.html → *.component.html.
Markdown page
---
title: Hello
date: 2026-07-02
---
<site-shell title="{{ title }}">
# Heading
Some **markdown** in the slot.
</site-shell>
Content inside a component tag is markdown-rendered. Front matter and MiniJinja work the same as HTML pages.
Components
components/site-shell.component.html becomes the site-shell tag. Attributes become template variables; slot content goes in the component slot variable.
Do not put a literal component tag in markdown prose — the expander treats it as a component. Use backticks around the tag name.
Equality checks in MiniJinja if blocks work in attribute values. Use ordinary quotes. Do not HTML-escape quotes inside the expression.
URL mapping
Source under pages/ | Published URL |
|---|---|
index.page.html | / or /index.html |
about.page.md | /about or /about.html |
blog/post.page.html | /blog/post |
threads/$threadId.page.html | /threads/:threadId (splat; threadId is a template variable) |
sketch/$uuid.api.sh | /sketch/:uuid |
Routing order: exact path, then {path}/index.html, then {path}.html, then $param splat files. Exact files win over splat. Links may omit .html.
Files under public/ are served at the site root. Tailwind entry CSS compiles at publish time (@import "tailwindcss"; @plugin "@tailwindcss/typography"). Wrap prose in prose prose-slate dark:prose-invert. Sails emits @layer base and @layer utilities only — @layer components is dropped.
Collections vs SQL
Glob other pages in front matter (glob: pages/blog/**) — see Collections. JSON / front-matter file sets use page filter: (same index as the filter command). App tables use SQL / sql --db.
Failure modes
| What you see | What it means | What to do |
|---|---|---|
| Folder served as raw files | No sails.site.json at mount root | Add the JSON at the path you passed to ship create |
publish: command not found | Binary is ship | ship create … |
| Template syntax error on first visit | MiniJinja failed | check --path site before you ship; fix quotes inside if expressions |
| New blog post missing from index | Collection cache stale or where dropped it | Autosave the new file. draft: true is excluded by draft != true; missing draft is null and still matches |
| Literal component tag in prose | Expander ate the tag | Backtick the tag name |
| SPA deep link 404s | No client fallback | Set spaFallbackPage (HTTP 200). Content sites that want a real 404 use notFoundPage |
How to: Publish a site, Check a site before you publish, Put a form on a site, Add sign-in to a site.