# One9x Pages — complete documentation Static site hosting. Push a folder, get an HTTPS URL. Deploys are atomic, rollback is instant, and every release keeps its own preview URL. Generated from https://one9x.com/docs. Sections appear in reading order. --- # One9x Pages docs Source: https://one9x.com/docs Deploy a static site from the dashboard, the CLI, GitHub Actions or an AI agent — then preview it, publish it, and roll it back. Pages hosts static sites — sites made of plain files (HTML, CSS, JavaScript, images, fonts). If you have a folder with an `index.html` in it — exported from an AI tool, or built by a framework — you can put it online. There's no server to set up: you bring the folder, and we serve the files. However you send the folder — the dashboard, the CLI, GitHub Actions or an AI agent — a deploy is the same four steps: 1. **Upload.** Send the build folder. Only files we don't already have move. 2. **Review.** See what is added, changed and removed against what is live. 3. **Preview.** The new version gets its own URL; production has not moved. 4. **Publish.** One switch, all at once — and the same switch rolls it back. [The dashboard](/docs/dashboard) always walks all four. [The CLI](/docs/cli) does too, unless you pass `--deploy` to collapse them into one step. ## Your first deploy The easiest way to put a site online is from your browser — there is nothing to install. You need one thing: the folder that contains your site's `index.html`. If you exported your site from an AI tool, that is usually the folder you unzipped. 1. **Sign in** at [portal.one9x.com](https://portal.one9x.com), or [create an account](https://portal.one9x.com/register). The free tier needs no card. 2. **Create a site.** Press **New site**, give it a title and a subdomain, and press **Create site**. The subdomain becomes the site's address: `yourname.one9x.app`. 3. **Drop your folder.** Open the site and, on the **Overview** tab, drop the folder that contains `index.html` onto the page — or press **…or choose a folder** to pick it instead. 4. **Review.** You see every file that is about to go online, and nothing has been sent yet. When it looks right, press **Upload and stage**. 5. **Publish.** The new version gets its own preview address, so you can check it before anyone else sees it. When you are happy, press **Make v1 live** in the green box that appears under the drop zone. 6. **You're live** at `https://yourname.one9x.app/`. If the site shows a "not found" page, the usual cause is a folder inside a folder — [Deploy from the dashboard](/docs/dashboard) shows how to check. ## If you prefer the terminal The shortest path from nothing to a live URL with the CLI, assuming you already have a built folder: ```console title="first deploy" $ curl -fsSL https://get.one9x.com | sh installed one9x → /usr/local/bin $ one9x login ✓ signed in as you@example.com $ one9x pages release ./dist --site mysite --deploy hashing ./dist … 248 files uploading … 248 changed, 0 unchanged ✓ v1 live in 2.1s → https://mysite.one9x.app/ ``` That is the whole thing. No build settings to fill in, no repository to connect, no framework to declare — you built the folder, we serve it. ## Where to go next | If you want to | Read | | --- | --- | | Deploy without installing anything | [Deploy from the dashboard](/docs/dashboard) | | Put a site from Lovable, Bolt, v0, ChatGPT or Claude online | [AI tools](/docs/ai-tools) | | Get the CLI and sign in | [Install & sign in](/docs/install) | | Start from a repo that already deploys | [Frameworks](/docs/frameworks) — a template per framework | | Know which folder your framework builds to | [Frameworks](/docs/frameworks) | | Deploy on every push | [GitHub Actions](/docs/github-actions) | | Preview a change before it goes live | [Releases & previews](/docs/releases) | | Put it on your own domain | [Custom domains](/docs/domains) | | Let an AI agent deploy for you | [Deploy from an agent](/docs/agents) | | Something didn't work | [Troubleshooting](/docs/troubleshooting) | ## Reading these docs as an agent Every page here is also served as markdown at the same path with `.md` appended — `/docs/cli.md`, `/docs/frameworks/nextjs.md`. There is an index at [`/llms.txt`](/llms.txt) and the whole documentation set concatenated into one document at [`/llms-full.txt`](/llms-full.txt). If you are an agent that can run commands, you probably don't want to read these docs at all: `one9x mcp` is a built-in MCP server that exposes deploys as tool calls. See [Deploy from an agent](/docs/agents). --- # Deploy from the dashboard Source: https://one9x.com/docs/dashboard Drop your site's folder in the browser to put it online — no install, and nothing is uploaded until you say so. There is nothing to install. Sign in, open a site, and on the **Overview** tab drop the folder that contains your `index.html` onto the **Deploy from your browser** panel — or press **…or choose a folder** if you would rather not drag. If you exported your site from an AI tool, that folder is the unzipped download. > **The most common mistake: a folder inside a folder.** Unzipping a download > often gives you a folder that holds *another* folder, and the site is in the > inner one. Before you drop, open the folder and look: `index.html` should be > right there at the top level. If all you see is a single folder, open it and > drop that one instead. In the review, the same check applies — `index.html` > should be at the top of the file list, not inside a folder. *Dropping the folder does not upload anything.* Your browser reads the folder and shows you what would change. Nothing is sent until you press **Upload and stage**, so dropping the wrong folder costs a moment and leaves nothing behind — close the review, press **Discard**, and drop the right one. When you do upload, only the files we don't already have are sent. The result is a *staged* release: a new version waiting at its own [preview address](/docs/releases), while your live site stays exactly as it was. A green box under the drop zone shows that address — open it to check the new version — and a **Make v1 live** button (the number is the new version's) that publishes it. On a site that is already live you confirm once more, since visitors will see the change straight away. The **Releases** tab has the same button for every version, which is also how you switch back to an older one. > **A browser upload never goes live on its own.** It always waits for you, and > publishing is a separate click — so nothing you do here can take a site down. > That is the one deliberate difference from the CLI, which can collapse both > steps with `--deploy`. Something not right? [Troubleshooting](/docs/troubleshooting) is organised by what you saw. ## Widgets The **Widgets** tab lists the site's widgets, each with a switch and its own page. **Forms** gives the site a place to send contact-form submissions, with no backend to run — every plan includes a Forms plan: define a form, paste the HTML it generates, and submissions appear in the dashboard ([Widgets](/docs/widgets)). **Comments** puts a moderated thread under any page ([Comments](/docs/comments)). ## It is the same release either way Everything after the upload is identical to a CLI deploy: the same releases, the same preview URLs, the same rollback. You can stage from the dashboard and publish from the CLI, or the other way round — they are one history, not two. Which means the dashboard is a reasonable place to *finish* a deploy that CI started. A [GitHub Actions](/docs/github-actions) run stages a release on every pull request; publishing it is a click here, or a merge, or a CLI command, whichever suits the moment. ## When the browser is the wrong tool Both ways in are plain web platform: the drop reads the dragged directory out of the drag event, and the **choose a folder** button is a directory-mode file input. If a drop can't be read for any reason, the panel says so and points you at the button — try that before assuming the folder is the problem. For a build of many thousands of files, the [CLI](/docs/cli) is noticeably quicker: it hashes with the machine's full parallelism rather than one tab's. ## Details If you use a framework, the folder that contains `index.html` is its build output — usually `dist/` or `build/`. [Frameworks](/docs/frameworks) lists the folder for each one. Dropping the project root instead of `dist/` is the developer version of the folder-inside-a-folder mistake. The drop hashes every file locally, in the tab, and compares those hashes with what the site already serves; that is how the review knows what changed before anything is sent. The files that are missing go straight to storage rather than through the dashboard. Dotfiles like `.git` and `.env` are left out of the release. --- # Install & sign in Source: https://one9x.com/docs/install Get the one9x binary on macOS, Linux or Windows, sign in once, and mint a machine token for CI. The CLI is a single static binary — the same `one9x` binary used across every One9x product, with Pages as a subcommand group. Install it, then sign in once to link the CLI to your account. ## macOS and Linux ```console title="install & sign in" $ curl -fsSL https://get.one9x.com | sh installed one9x → /usr/local/bin $ one9x login opening browser to authorize … ✓ signed in as you@example.com ``` The installer is a shell script and drops the binary in `/usr/local/bin`. ## Windows ```console title="powershell" $ irm https://get.one9x.com/install.ps1 | iex installing one9x (windows/amd64) … ✓ one9x.exe → %LOCALAPPDATA%\Programs\one9x $ one9x login ✓ signed in as you@example.com ``` It drops `one9x.exe` in `%LOCALAPPDATA%\Programs\one9x` and adds it to your PATH — no admin rights needed, and it works in the same terminal. ## Placing the binary yourself The binaries are plain downloads — `get.one9x.com//one9x-windows-amd64.exe` (or `-arm64`), with the current version at `get.one9x.com/VERSION`. macOS and Linux builds sit alongside them under the same paths. ## Signing in `one9x login` authorizes the CLI against your One9x account and stores a token locally — there's no API key to copy around, and it renews itself so you sign in once per machine. Every Pages command runs as you, against your sites. On a server with no browser, add `--no-browser` and approve the printed URL from any other device. > You need an account before the CLI can log in. [Create > one](https://portal.one9x.com/register), confirm your email, then run `one9x > login`. ## Machine tokens, for CI and agents For CI, cron and agents, mint a machine token instead of logging in. Set it as `ONE9X_TOKEN` and every command authenticates with no browser: ```console title="ci" $ one9x tokens create "ci" one9x_tok_7Kd… (shown once) $ export ONE9X_TOKEN=one9x_tok_… $ one9x pages release ./dist --site mysite --deploy ✓ deployed ``` The token is printed once and then hashed — store it when you see it, because it can never be shown again. **Tokens last 90 days by default** (`--expires-in` to choose, up to a year). That is the single most likely way a working pipeline stops working, so it is worth knowing the fix before you need it: `one9x tokens extend ` pushes the expiry out *without* changing the secret, so CI keeps running with the value it already has. `one9x tokens list` shows what exists, when each was last used and when it expires; `one9x tokens revoke ` kills one immediately. If you are wiring up GitHub Actions, the token goes in a repository or organization secret — see [GitHub Actions](/docs/github-actions). --- # How to host an AI-generated website Source: https://one9x.com/docs/ai-tools Built your site with Lovable, Bolt, v0, ChatGPT or Claude? Where to host it, which folder to drop, and the two settings that trip people up. If an AI tool built your site, putting it online comes down to two questions: **which folder do you drop**, and **does the site need single-page app mode**. The page for your tool answers both. | Tool | What you get from it | Folder to drop | Single-page app mode? | | --- | --- | --- | --- | | [Lovable](/docs/ai-tools/lovable) (React and Vite projects) | the project's code | the folder its build creates | yes | | [Bolt](/docs/ai-tools/bolt) | the project's code | the folder its build creates | usually | | [v0](/docs/ai-tools/v0) | the project's code | depends on the project | depends on the project | | [ChatGPT or Claude](/docs/ai-tools/chatgpt-and-claude) | an HTML file | the folder you save `index.html` in | no | Whatever the tool, the folder you drop must have `index.html` at its top level — not inside another folder. [Deploy from the dashboard](/docs/dashboard) shows how to check. ## Start with the prompt Before the tool builds anything — or before you publish something it already built — paste [the One9x prompt](/docs/ai-tools/prompt). It tells the tool to make plain static files with `index.html` at the top, to wire any form to your site's built-in form service, and to list what you need to replace before going live. It works with every tool on this page. ## Code, or a finished site? ChatGPT and Claude usually hand you an HTML file, and an HTML file is already a finished page: save it and drop it. Lovable, Bolt and v0 give you the *code* for a project. A browser can't open that code directly — it has to be turned into a finished site first, which is one command on your own computer (a *build*). The build creates a new folder with `index.html` in it, and that new folder is the one you drop. Each tool's page has the exact steps. ## Add a contact form Most generated sites come with a contact form that posts nowhere. Every site on One9x has a form service built in — every plan includes it, no database or email service to connect. Define the form under the dashboard's **Widgets → Forms**, then hand the HTML it generates back to your AI tool. [Widgets](/docs/widgets#with-an-ai-tool) has the steps and a sample prompt. ## Single-page app mode Many sites made with these tools have several pages — `/about`, `/pricing` — but only one real HTML file, with the pages drawn in the browser. Online, that works when a visitor clicks a link and fails when they open `/about` directly or refresh on it: they get a "not found" page. Single-page app mode fixes that. In the dashboard, when you review an upload, select **Deploy options** at the top of the file list and choose **Single-page app** under **Unknown paths**. It is part of each upload, so choose it again each time you drop a new version. [SPA mode & error pages](/docs/spa-and-errors) has the details, including the CLI flag. Built something with a framework you chose yourself? [Frameworks](/docs/frameworks) has a page for each. --- # How to host a Lovable site Source: https://one9x.com/docs/ai-tools/lovable Host a Lovable project built with React and Vite: download the code, build it once, drop the folder it creates, and turn on single-page app mode. This page is for Lovable projects built with **React and Vite**. You download the project's code, turn it into a finished site with one build on your computer, and drop the folder that creates. ## Check which kind of project you have Lovable builds projects in more than one way, and the setup it uses has changed over time. Some projects use TanStack Start, which builds each page on a server when someone visits it. Pages hosts finished files and runs no server, so **these steps don't work for TanStack Start projects**. Lovable's docs explain which setups it uses and how to tell which one your project has: [What tech stacks does Lovable use?](https://docs.lovable.dev/introduction/faq) The quickest way is to ask Lovable: *What stack is this project on?* Once you have the code, you can also open `package.json`: if it lists `@tanstack/react-start`, the project uses TanStack Start. > **Starting a new site, or about to publish?** Paste [the One9x > prompt](/docs/ai-tools/prompt) into the tool first — it builds static files, > wires any form to your site's built-in form service, and keeps inside the limits. ## 1. Get the code - **On a paid Lovable plan:** open the code editor and select **Download codebase** at the bottom of the file panel. The same option is under **Project settings → Git**. ([Lovable: download your project's codebase](https://docs.lovable.dev/features/code-mode)) - **On the free plan:** connect the project to GitHub with Lovable's **Git sync**, then download the repository from GitHub with **Code → Download ZIP**. ([Lovable: Git sync](https://docs.lovable.dev/integrations/git-sync-overview)) Unzip it. ## 2. Build it You need [Node.js](https://nodejs.org) installed once. Then open a terminal in the unzipped folder and run: ```console title="build" $ npm install $ npm run build ✓ built in 2.31s ``` The build creates a new folder inside the project, with `index.html` in it. That new folder is the one you drop — see [Details](#details) for its name. ## 3. Drop it and turn on single-page app mode Drop the folder on your site's **Overview** tab, as in [Deploy from the dashboard](/docs/dashboard). In the review, select **Deploy options** and choose **Single-page app** under **Unknown paths**, then **Upload and stage** and **Make live**. Lovable apps draw every page in the browser from one `index.html`, so without this setting anyone who refreshes on `/about` or opens a shared link to it gets a "not found" page. [SPA mode & error pages](/docs/spa-and-errors) explains why. ## Add a contact form If the site has a contact form, it needs somewhere to send submissions. Your One9x site already has one, free: define the form under **Widgets → Forms** and ask the tool to use the HTML the dashboard generates. [Widgets](/docs/widgets#with-an-ai-tool) has the steps and a sample prompt. ## Common mistakes **Dropping the whole project.** If the review lists `package.json`, `src` or `node_modules`, you dropped the project instead of the folder the build created. Discard it and drop the inner folder. **A folder inside a folder.** `index.html` must be at the top level of what you drop, not one folder further down. **A blank page when you double-click `index.html` on your computer.** That is expected: the page looks for its files at the top of a website (`/assets/…`), which only exists once it's online. It is not a sign the upload will fail. **A blank page online, with missing scripts.** The project is set to live under a sub-folder of a website instead of at the top. [Vite](/docs/frameworks/vite#base-path) explains the setting. ## Details Lovable projects build with Vite, so `npm run build` writes `dist/`. From the CLI: ```console title="lovable" $ one9x pages release ./dist --site mysite --spa --deploy ✓ v1 live · unknown routes → /index.html → https://mysite.one9x.app/ ``` Everything on the [Vite](/docs/frameworks/vite) page applies, including deploying on every push with [GitHub Actions](/docs/github-actions) once the project is on GitHub. --- # How to host a Bolt.new site Source: https://one9x.com/docs/ai-tools/bolt Host a Bolt.new project: download the code, build it once, and drop the folder it creates — with single-page app mode if it has several pages. Bolt projects are usually React apps built with Vite. You download the project's code, turn it into a finished site with one build on your computer, and drop the folder that creates. > **Starting a new site, or about to publish?** Paste [the One9x > prompt](/docs/ai-tools/prompt) into the tool first — it builds static files, > wires any form to your site's built-in form service, and keeps inside the limits. ## 1. Get the code In Bolt, open your project, select the **project title** at the top left, then **Export → Download**. Unzip the file it saves. ([Bolt: download a project](https://support.bolt.new/building/using-bolt/projects-files)) ## 2. Build it You need [Node.js](https://nodejs.org) installed once. Then open a terminal in the unzipped folder and run: ```console title="build" $ npm install $ npm run build ✓ built in 2.07s ``` The build creates a new folder inside the project, with `index.html` in it. That new folder is the one you drop — see [Details](#details) for its name. These steps are for a Vite project, which has a `vite.config` file. Bolt can also make projects with other frameworks, such as Astro or Next.js. If yours has an `astro.config` or `next.config` file instead, follow that framework's page under [Frameworks](/docs/frameworks). ## 3. Drop it and turn on single-page app mode Drop the folder on your site's **Overview** tab, as in [Deploy from the dashboard](/docs/dashboard). If your site has more than one page but the folder has only one `index.html`, select **Deploy options** in the review and choose **Single-page app** under **Unknown paths**. Then **Upload and stage** and **Make live**. Without it, anyone who refreshes on `/about` or opens a shared link to it gets a "not found" page. [SPA mode & error pages](/docs/spa-and-errors) explains why. ## Add a contact form If the site has a contact form, it needs somewhere to send submissions. Your One9x site already has one, free: define the form under **Widgets → Forms** and ask the tool to use the HTML the dashboard generates. [Widgets](/docs/widgets#with-an-ai-tool) has the steps and a sample prompt. ## Common mistakes **Dropping the whole project.** If the review lists `package.json`, `src` or `node_modules`, you dropped the project instead of the folder the build created. Discard it and drop the inner folder. **A folder inside a folder.** `index.html` must be at the top level of what you drop, not one folder further down. **A blank page when you double-click `index.html` on your computer.** That is expected: the page looks for its files at the top of a website (`/assets/…`), which only exists once it's online. It is not a sign the upload will fail. **A blank page online, with missing scripts.** The project is set to live under a sub-folder of a website instead of at the top. [Vite](/docs/frameworks/vite#base-path) explains the setting. ## Details A Vite project writes `dist/`. From the CLI, with client-side routing: ```console title="bolt" $ one9x pages release ./dist --site mysite --spa --deploy ✓ v1 live · unknown routes → /index.html → https://mysite.one9x.app/ ``` If the project is not Vite, [Frameworks](/docs/frameworks) has the output folder and the `--spa` answer for each framework. --- # How to host a v0 project Source: https://one9x.com/docs/ai-tools/v0 Host a v0 project: download the code, build it as static output, drop the folder it creates — single-page app mode only if it is a Vite app. v0 makes Next.js apps unless you ask for something else. It can also make other kinds of project, such as Vite apps. ([v0: full-stack apps](https://v0.app/docs/full-stack-apps)) Pages hosts finished sites made of plain files, so a Next.js app needs one setting to build that way, then one build on your computer, and you drop the folder that creates. > **Starting a new site, or about to publish?** Paste [the One9x > prompt](/docs/ai-tools/prompt) into the tool first — it builds static files, > wires any form to your site's built-in form service, and keeps inside the limits. ## 1. Get the code Open the project's menu in v0 and select **Download ZIP**. Unzip the file it saves. If the project is connected to GitHub, you can download it from GitHub instead. ([v0: GitHub](https://v0.app/docs/github)) ## 2. Build it **If it is a Next.js app** — the project has a `next.config` file — follow the [Next.js](/docs/frameworks/nextjs) page. It needs one change to the config, and some features, like API routes and server actions, can't be used on a static host. That page lists them. **If it is a Vite app** — the project has a `vite.config` file — you need [Node.js](https://nodejs.org) installed once. Open a terminal in the unzipped folder and run: ```console title="build" $ npm install $ npm run build ✓ built in 1.94s ``` Either way, the build creates a new folder inside the project, with `index.html` in it. That new folder is the one you drop — see [Details](#details) for its name. ## 3. Drop it Drop the folder on your site's **Overview** tab, as in [Deploy from the dashboard](/docs/dashboard), then **Upload and stage** and **Make live**. **Single-page app mode depends on the project.** A Next.js build writes a real HTML file for every page, so leave it off. A Vite app draws its pages in the browser from one `index.html`, so turn it on: in the review, select **Deploy options** and choose **Single-page app** under **Unknown paths**. Without it, anyone who refreshes on `/about` gets a "not found" page. [SPA mode & error pages](/docs/spa-and-errors) explains why. ## Add a contact form If the site has a contact form, it needs somewhere to send submissions. Your One9x site already has one, free: define the form under **Widgets → Forms** and ask the tool to use the HTML the dashboard generates. [Widgets](/docs/widgets#with-an-ai-tool) has the steps and a sample prompt. ## Common mistakes **Dropping the whole project.** If the review lists `package.json`, `app`, `src` or `node_modules`, you dropped the project instead of the folder the build created. Discard it and drop the inner folder. **A folder inside a folder.** `index.html` must be at the top level of what you drop, not one folder further down. **A blank page when you double-click `index.html` on your computer.** That is expected: the page looks for its files at the top of a website (`/_next/…` or `/assets/…`), which only exists once it's online. It is not a sign the upload will fail. **Links work on the home page and every other page is "not found".** A Next.js setting, `trailingSlash`, is missing. The [Next.js](/docs/frameworks/nextjs) page covers it. ## Details A Next.js static export writes `out/`; a Vite app writes `dist/`. From the CLI: ```console title="v0" $ one9x pages release ./out --site mysite --deploy ✓ v1 live → https://mysite.one9x.app/ ``` For a Vite app, point at `./dist` and add `--spa`. --- # How to host a website from ChatGPT or Claude Source: https://one9x.com/docs/ai-tools/chatgpt-and-claude Host the HTML that ChatGPT or Claude wrote: save the page as index.html in its own folder, keep the images next to it, and drop the folder. When ChatGPT or Claude writes a web page for you, you usually get HTML — and an HTML file is already a finished page. There is nothing to build: save it in a folder and drop the folder. > **Starting a new site, or about to publish?** Paste [the One9x > prompt](/docs/ai-tools/prompt) into the tool first — it builds static files, > wires any form to your site's built-in form service, and keeps inside the limits. ## 1. Save the page Create a new, empty folder for your site. Save the page into it with the name **`index.html`** — that is the file a visitor sees first. If the tool gave you more than one page, save the others in the same folder with names like `about.html`. - **Claude:** when the page opens in its own panel (an *artifact*), use the download option at the bottom right of that panel. ([Claude: what are artifacts](https://support.claude.com/en/articles/9487310-what-are-artifacts-and-how-do-i-use-them)) - **ChatGPT:** the page comes back as a block of code in the chat. Copy it with the block's copy button, paste it into a plain text editor, and save it as `index.html`. (ChatGPT's older *canvas* panel had a download button, but OpenAI is retiring it: [ChatGPT release notes](https://help.openai.com/en/articles/6825453-chatgpt-release-notes).) Any other tool works the same way: use its download button if it has one, otherwise copy the code and save it as `index.html`. ## 2. Add your images Put any images, fonts or other files the page uses into the same folder — or a folder inside it, like `images` — so everything the site needs is in one place. ## 3. Drop it Drop the folder on your site's **Overview** tab, as in [Deploy from the dashboard](/docs/dashboard), then **Upload and stage** and **Make live**. A site like this does not need single-page app mode. ## Add a contact form If the site has a contact form, it needs somewhere to send submissions. Your One9x site already has one, free: define the form under **Widgets → Forms** and ask the tool to use the HTML the dashboard generates. [Widgets](/docs/widgets#with-an-ai-tool) has the steps and a sample prompt. ## Common mistakes **The file isn't called `index.html`.** A page saved as `landing.html` or `page.html` works at `/landing.html`, but the site's home address shows "not found". Rename it to `index.html`. **It saved as `index.html.txt`.** Some editors add `.txt` without showing it. Check the file's full name before you drop the folder. **Images show on your computer but not online.** Look at how the page refers to each image: - `C:\Users\you\Desktop\photo.jpg` or `file:///…` points at *your* computer. Nobody else can see it. Copy the image into the site's folder and change the address. - `images/photo.jpg` — no slash at the start — is *relative*: it means "the `images` folder next to this page". That works on your computer and online. - `/images/photo.jpg` — with a slash at the start — is *absolute*: it means "from the top of the website". It works online, but not when you double-click the file on your computer. Also check that capital letters match: online, `Photo.JPG` and `photo.jpg` are two different files. **The tool gave you code with `import` and `export` lines, or JSX.** That is a React component, not a finished page, and a browser can't open it on its own. Ask the tool for "a single, complete HTML file" instead, or see [Frameworks](/docs/frameworks) if you want to build it properly. **A folder inside a folder.** `index.html` must be at the top level of what you drop, not one folder further down. ## Details The folder is served as-is: `/` is `index.html` and `/about` is `about/index.html`. If you link to `about.html`, keep the `.html` in the link. From the CLI: ```console title="html" $ one9x pages release ./my-site --site mysite --deploy ✓ v1 live → https://mysite.one9x.app/ ``` --- # A prompt that builds your site for One9x Source: https://one9x.com/docs/ai-tools/prompt Paste this into ChatGPT, Claude, Lovable, Bolt, v0 or Cursor before it builds — the site comes out as static files, forms already wired, ready to drop. Most trouble with an AI-built site shows up at the end: a React project instead of a finished site, a form that posts nowhere, a server the site cannot have. All of it is avoidable if the tool knows where the site is going **before** it starts. Paste this at the start of your chat — or into a chat where the site is already built — and you get something you can drop straight onto One9x. It works whether or not the tool can open web pages: the rules it needs are in the prompt, and a tool that can browse also reads our docs. ## The prompt ```text I'm going to host this website on One9x Pages (https://one9x.com), a static site host. If you can open web pages, read https://one9x.com/llms.txt and the pages it links to before you start. Then follow these rules: 1. Static files only. The finished site is plain HTML, CSS, JavaScript and assets. No server code: no PHP, no Node or Express server, no API routes, no serverless functions, no database, no server-side rendering. 2. No build step if possible: plain HTML, CSS and vanilla JavaScript that works by opening index.html in a browser. If you do use a framework (React, Vite, Next.js static export), tell me the exact build command and the folder it creates (for example dist/). That folder is what I upload, not the project's source code. 3. index.html sits at the top level of the folder I upload. Other pages are their own HTML files (about.html, or about/index.html). If pages are drawn in the browser (React Router or similar), remind me to choose "Single-page app" in Deploy options when I upload. 4. Relative paths for links and assets (images/logo.webp), never file:/// or C:\ paths. Match upper and lower case exactly in file names. 5. Keep it small: every file under 10 MB and fewer than 500 files in total. Compress images (WebP or JPEG at a sensible width). 6. No secrets: no API keys, passwords or tokens in any file. Everything I upload is public. 7. Forms: do not connect them to a database, CRM, email service or a third-party form service. One9x has a built-in form service. For every form: - post to /_one9x/forms/ with method="POST", where is lowercase letters, digits, - or _ (for example: enquiry); - use simple field names: name, email, phone, message; - put this hidden spam trap inside the form, exactly as written: - put this spam check inside the form, just before the submit button: - and load its script once on the page: - to show my own thank-you page, add and create thanks.html. Do not add JavaScript that submits the form; it posts on its own. 8. When you are done, give me: - every file, each with its full path inside the site folder; - for each form: its name and its field names, each with a type (text, long text, email, number, checkbox) and whether it is required, so I can create the same form in the One9x dashboard; - a checklist of placeholders I must replace before publishing: phone numbers, addresses, prices, reviews, photos. If you have already built a site for me in this chat, don't start over: check that site against every rule above, fix everything that breaks them, and give me the complete, corrected files as in rule 8. ``` The limits in rule 5 are the Free plan's. On a paid plan they are higher — see [Limits](/docs/limits) and loosen the numbers to match. **Already have a site?** Paste the same prompt into the chat where the tool built it. The last line has the tool check what it made against every rule and hand back corrected files instead of starting over — worth doing **before publishing** anything built without the prompt, while a source-code folder, a form that goes nowhere or a hard-coded key is still cheap to fix. ## Then put it online 1. **Save the files into one folder**, using the paths the tool gave you. 2. **If the site has a form**, open your site's **Widgets → Forms** in the dashboard, turn on **Accept form submissions**, and create each form with the name and fields the tool listed — the names must match exactly. Every plan includes 250 submissions a month or more. [Widgets](/docs/widgets) has the details. 3. **Drop the folder** on your site's **Overview** tab, then **Upload and stage** and **Make live**, as in [Deploy from the dashboard](/docs/dashboard). 4. **Test the form once** yourself and check the submission shows up under **Widgets → Forms**. If the tool used a framework after all, build it first and drop the folder the build creates — the page for [your tool](/docs/ai-tools) has the steps. --- # Deploy from the CLI Source: https://one9x.com/docs/cli Point one9x pages release at a build folder, choose whether it goes live, and control what gets uploaded. Point the CLI at your build output and give the site a name with `--site`. The site name is a DNS label — lowercase letters, digits and hyphens, 1–63 characters — and it becomes your subdomain. ```console title="release" $ one9x pages release ./dist --site mysite --deploy hashing ./dist … 248 files uploading … 41 changed, 207 unchanged ✓ v8 live in 1.4s → https://mysite.one9x.app/ ``` The CLI hashes your files locally and uploads only the ones that actually changed since your last release, so the first push is quick and every push after is quicker — an unchanged re-release uploads nothing. The new version goes live in a single atomic flip: visitors are served either the old version or the new one, never a half-uploaded mix. The full mechanics are in [How deploys work](/docs/how-it-works). `--deploy` is what makes it live. Leave it off and you get a [staged release with a preview URL](/docs/releases) instead. ## What gets uploaded Everything in the directory except dot-paths — `.git`, `.env` and anything else beginning with a dot stays on your machine. `.well-known` is the one exception, because it's a reserved public path: `assetlinks.json` and `apple-app-site-association` only work at that exact URL, so Android App Links and iOS universal links verify. To hold anything else back, pass `--exclude` with a glob. It repeats, and each pattern is matched against the path relative to your directory *and* against the file name — so `'*.map'` catches source maps at any depth, while `'admin/*'` stays where you wrote it. Excluding a directory drops everything under it. Quote the pattern, or your shell will expand it first. ```console title="exclude" $ one9x pages release ./dist --site mysite --deploy \ --exclude '*.map' --exclude admin 12 path(s) excluded ✓ v9 live ``` > Patterns are single-segment `path.Match` globs. `**` is **not** supported — a > pattern like `'admin/**/*.map'` matches nothing rather than erroring. Match on > the file name (`'*.map'`) or exclude the directory (`admin`) instead. ## Choosing the site `--site` accepts more than the subdomain: a site name, a full platform host, a custom domain you have linked, or a numeric id all resolve to the same site. Use whichever you have to hand. If the site does not exist yet, create it first — `one9x pages create mysite` — or make one in the dashboard. A release will not conjure a site, because a typo in `--site` would then silently publish your build to a brand-new address instead of the one you meant. ## Deploying the same content twice Re-releasing a folder that has not changed is free and safe: the version id is the content hash, so an identical build produces an identical version and nothing is uploaded. That matters most in CI, where a README-only commit still triggers the job. ## Build your command There is an interactive builder for `one9x pages release` on the HTML version of this page — tick what you need and copy the result. Every flag it offers is in the [CLI reference](/docs/cli-reference), which is the complete list either way. --- # Releases & previews Source: https://one9x.com/docs/releases Staging and publishing are two separate verbs — every release gets an immutable version and its own preview URL. Building and going live are two verbs, not one. `release` uploads a directory and creates an immutable version; `deploy --to` decides which version production serves. Adding `--deploy` to a release does both in one step, which is the common case. Without `--deploy` the release is *staged*: it exists, it's fully uploaded, and it's reachable at its own preview URL — but production keeps serving whatever it was already on. Check the preview, then promote it. ```console title="stage, check, promote" $ one9x pages release ./dist --site mysite ✓ v9 staged · production still on v8 → https://v9--mysite.one9x.app/ $ one9x pages deploy --site mysite --to v9 active version v8 → v9 ✓ Live · nothing re-uploaded ``` ## Preview URLs A preview URL is `v--.one9x.app` — the same content, the same edge, the same certificate, served from the same immutable files. It costs nothing extra to keep around: previews share every unchanged file with production. > **Preview URLs are public.** Treat them as unlisted rather than private — > anyone with the link can open one. There is no crawl path to them, but there > is no login on them either. A preview only exists once a release does — but it does **not** wait for anything to go live. A site's very first release is previewable while it is still staged, which is what makes `one9x pages release` without `--deploy`, and the GitHub Action's preview comment on a pull request, work on a site nobody has published yet. A site with no releases at all has no preview to serve. Its platform host says so rather than 404ing into a void: the page names the site, says nothing is live yet, and gives you the command that publishes. ## Naming a release Every release gets an automatic per-site number — `v1`, `v2`, `v3` — and you can add your own label with `--tag`. Both work as a `--to` target, as does a version-hash prefix. ```console title="tags" $ one9x pages release ./dist --site mysite --tag prod ✓ v10 staged · tag prod $ one9x pages deploy --site mysite --to prod ✓ Live ``` ## How long releases are kept Retention is count-based, not time-based, so how far you can roll back doesn't depend on how often you ship. The last 5 untagged releases are kept on the free tier — paid tiers keep more — and older ones age out. Tagged releases never age out and don't use up one of those slots, but there's a cap of 5 tags per site. At the cap a tagged release **fails with an error** rather than quietly dropping one of your tags. `one9x pages releases rm` frees a slot. --- # Reviewing a change Source: https://one9x.com/docs/diff one9x pages diff answers "what happens if I publish this?" — file by file, against whatever is live. `one9x pages diff` answers "what happens if I publish this?" — file by file, against whatever is live right now. The dashboard shows the same comparison automatically before an upload. ```console title="review" $ one9x pages diff v9 --site mysite v9 vs v8 ~ /index.html 12.4 KiB (+1.08 KiB) + /about/index.html 4.1 KiB - /old.html (was 2.2 KiB) 1 added, 1 changed, 1 removed, 29 unchanged ``` `--against` picks a different baseline — a release number, a tag, a version-hash prefix, or `live` (the default). `--against none` skips the comparison and just lists the files. `--all` includes the unchanged ones. > Worth running in CI right after a staged release: a one-line content edit that > reports 200 changed files usually means the build changed, not the content — a > bumped asset hash, a stray `node_modules`, a missing `--exclude`. ## What the diff does not cover The diff compares **files**. It does not compare a release's settings, so two releases that differ only in `--spa`, `--index` or an `--error` mapping show as "identical file trees" while genuinely being different releases. That is worth knowing before you use the diff as a decision procedure: if you want to know whether two releases are the same thing, compare their version ids (`one9x pages releases list`), not their diffs. The version id is the content hash of the whole manifest — files *and* settings — so identical ids mean identical releases and nothing else does. --- # SPA mode & error pages Source: https://one9x.com/docs/spa-and-errors Serve /index.html for unmatched routes so client-side routers work, and map status codes to your own pages. Two options cover the common routing cases for static sites. ## Single-page apps Add `--spa` and any path that doesn't match a file is served `/index.html` with a `200` — so client-side routers (React Router, Vue Router, TanStack Router) handle deep links instead of returning a 404. ```console title="spa" $ one9x pages release ./dist --site myapp --spa --deploy ✓ Live · unknown routes → /index.html ``` This is the flag that decides whether an app *works*. Without it, the first visitor who hard-refreshes on `/settings` or follows a shared deep link gets a 404 — the app is fine, the routing never got a chance to run. You need it for a client-rendered app (Vite + React Router, Vue Router, Create React App). You don't need it for a static site generator that writes a real HTML file per route — Astro, Hugo, Eleventy, Next.js `output: 'export'` — where an unmatched path genuinely is a 404 and should say so. [Frameworks](/docs/frameworks) has the per-framework answer. > `--spa` and `--index` are the same slot and conflict. `--index` names a > different fallback document; `--spa` is the shorthand for `--index > /index.html`. Pass one or the other. ## Custom error pages Map a status code to a page in your build with `--error code:path`. The flag repeats, so you can set as many as you need. ```console title="error pages" $ one9x pages release ./dist --site mysite --deploy \ --error 404:/404.html \ --error 403:/403.html ✓ Live · custom 404, 403 ``` The path is a path *within the release* — it has to be a file the build actually produced, at the exact path it lives at. `--error 404:/404.html` with the file at `/404/index.html` maps to nothing. Error pages and `--spa` interact the way you would want: with `--spa` on, an unmatched route is not an error at all, so the 404 page is only reached when the app itself asks for it. --- # Release history Source: https://one9x.com/docs/history Every release is recorded per site, newest first, with its number, tag, version hash and status. Every release is recorded for the site, newest first. The `STATUS` column says what each one is: `live` is the release production is serving, `staged` is one that exists but hasn't been deployed, and `kept` is a tagged release held back from retention. ```console title="history" $ one9x pages releases list --site mysite SEQ VERSION TAG WHEN STATUS v9 sha256:5c1904ab 2m ago staged v8 sha256:9f2a1b3c prod 1h ago live v7 sha256:41de88a0 3h ago v6 sha256:aa03f1cc rc1 1d ago kept ``` ## Version ids A version id is the content hash of that release's manifest. The release number (`v8`) and any tag are the friendlier way to refer to it; the hash is there when you want to be certain which bytes you mean, and a prefix of it works anywhere a release ref does. Because it is a hash of the content, it is also the answer to "did anything actually change?" — two releases with the same version id are the same release, whatever their numbers say. ## Deleting a release ```console title="delete" $ one9x pages releases rm v6 --site mysite ✓ removed v6 · tag rc1 freed ``` The live release can't be deleted — deploy another one first. Deleting a tagged release frees its tag slot, which is the usual reason to do it. --- # Rolling back Source: https://one9x.com/docs/rollback There is no rollback command, because rolling back is the same pointer flip as going live — and it is instant. Because every version is an immutable manifest of immutable, content-addressed files, no release ever overwrites another. Rolling back is just re-pointing the site at an earlier one — the files are already there, so it takes effect instantly with nothing to rebuild and nothing to re-upload. Keeping a long history is nearly free: shared files are stored once, so you're never charged twice for the same bytes. There's no separate rollback command, because rolling back is the same operation as going live: `deploy --to` points the site at a release. It takes a release number, a tag, or a version-hash prefix. ```console title="roll back" $ one9x pages deploy --site mysite --to v6 active version v7 → v6 ✓ Live · nothing re-uploaded ``` ## The one you want at 3am An empty `--to ""` means the release before the active one: ```console title="undo the last deploy" $ one9x pages deploy --site mysite --to "" active version v9 → v8 ✓ Live · nothing re-uploaded ``` That is the one you want when a deploy has just gone wrong and you don't want to go looking up a number first. ## Rolling forward again Nothing is lost by rolling back — the release you rolled away from is still there, still staged, still at its own preview URL. Fix the problem, or just point back at it: ```console title="forward" $ one9x pages deploy --site mysite --to v9 ✓ Live ``` --- # GitHub Actions Source: https://one9x.com/docs/github-actions Deploy on every push with one9x/pages-deploy — preview URLs on pull requests, publish on merge. [`one9x/pages-deploy`](https://github.com/marketplace/actions/deploy-to-one9x-pages) is a composite action that installs the CLI, runs one `pages release` and hands back the URL. Pull requests get a preview; merges go live. ## Skip this page If you are starting something new, there is a **template repository per framework** with this workflow already in it — press *Use this template*, set two values, and you are deploying: [vite](https://github.com/one9x/template-vite) · [nextjs](https://github.com/one9x/template-nextjs) · [astro](https://github.com/one9x/template-astro) · [nuxt](https://github.com/one9x/template-nuxt) · [sveltekit](https://github.com/one9x/template-sveltekit) · [create-react-app](https://github.com/one9x/template-create-react-app) · [hugo](https://github.com/one9x/template-hugo) · [eleventy](https://github.com/one9x/template-eleventy) Each one deploys itself to a live demo on every push, so what you copy is something that is currently working rather than something that once did. Want a whole site rather than a framework? [Templates](/templates) also has a blog, a docs site, a landing page and a portfolio, wired the same way. The rest of this page is for adding the action to a repo you already have. ## Setup, once Two things: a **site** to deploy to, and a **token** for CI. ```console title="setup" $ one9x pages create mysite ✓ created mysite → https://mysite.one9x.app/ $ one9x tokens create "github actions" one9x_tok_7Kd… (shown once) ``` Both can also be done from the dashboard. Store the printed token as a repository secret named **`ONE9X_TOKEN`** (Settings → Secrets and variables → Actions), or as an organization secret if several repos deploy. The action deploys to a site that **already exists** — it will not create one. A typo in `site` should fail, not quietly publish your build to a brand-new address. > **Tokens expire after 90 days.** `one9x tokens extend ` pushes that out > without changing the secret, so the stored value keeps working. A silently > expired token is the most likely way a pipeline that has run for months starts > failing. ## Preview on every PR, publish on merge One workflow covers both, because `deploy` is just a string the caller computes: ```yaml name: Pages on: pull_request: push: branches: [main] permissions: contents: read pull-requests: write # only the PR comment needs this concurrency: group: pages-${{ github.ref }} # A superseded preview is worth cancelling; a live deploy is not. cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: pages: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: { node-version: 22, cache: npm } - run: npm ci - run: npm run build - id: pages uses: one9x/pages-deploy@v1 with: site: mysite dir: dist token: ${{ secrets.ONE9X_TOKEN }} # THE ONE LINE that decides preview vs live. deploy: ${{ github.ref == 'refs/heads/main' }} comment: true - if: steps.pages.outputs.live == 'true' run: echo "live at ${{ steps.pages.outputs.url }}" ``` A pull request **stages**: the release uploads but nothing goes live, and the action comments the preview URL on the PR, editing the same comment on every later push rather than adding another. A push to `main` **publishes**. Staging costs nothing — blobs are already uploaded and publishing is a pointer flip, so the preview a reviewer approved is byte-identical to what goes live. Your build folder is whatever your framework produces; [Frameworks](/docs/frameworks) has the table. ## Requiring an approval before it goes live If "merged to `main`" is not enough and you want a human to press a button, put the deploy in its own job with a GitHub **environment** that has required reviewers: ```yaml publish: needs: pages if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest environment: production # required reviewers live on the environment steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: { node-version: 22, cache: npm } - run: npm ci && npm run build - uses: one9x/pages-deploy@v1 with: site: mysite dir: dist token: ${{ secrets.ONE9X_TOKEN }} deploy: true ``` Don't try to express this as a conditional `environment:` on one job — an empty environment name is an error, not a skip. ## Inputs | Input | What it maps to | Default | | --- | --- | --- | | `site` **(required)** | `--site` | — | | `dir` **(required)** | the folder to deploy | — | | `token` **(required)** | `ONE9X_TOKEN` | — | | `deploy` | `--deploy` | `false` | | `spa` | `--spa` | `false` | | `index` | `--index` | — | | `error-pages` | one `:` per line | — | | `exclude` | one glob per line | — | | `tag` | `--tag` | — | | `cli-version` | which CLI to install | pinned per action release | | `portal` | `ONE9X_PORTAL` | `https://portal.one9x.com` | | `comment` | post/update the PR comment | `false` | | `comment-template` | markdown body for that comment | built-in | | `github-token` | token used to post the comment | `${{ github.token }}` | **Booleans are checked, not coerced.** `deploy: yes` and `deploy: 1` are the obvious things to write and both fail the run with a message naming what you passed. They are not read as `false` — a misconfiguration whose only symptom is "the site didn't change" is worse than a red run. ## Outputs | Output | | | --- | --- | | `url` | The live URL. Empty unless this run published. | | `preview-url` | The preview URL. Empty unless this run staged. | | `version` | The release version — a content hash. | | `seq` | The release number, as in `v6`. | | `live` | `"true"` if this release is the one being served. | `url` and `preview-url` are mutually exclusive by design: a run either published or staged, and the one that didn't happen is empty rather than stale. ## The PR comment `comment: true` posts one comment and then **edits it in place** on every later push — a twenty-commit PR gets one comment, not twenty. The marker it looks for is keyed on the site name, so a monorepo deploying several sites to the same PR keeps one comment each. Override the body with `comment-template`. The placeholders are `{site}`, `{url}`, `{preview_url}`, `{version}`, `{seq}` and `{commit}` — single braces, because `${{ }}` belongs to GitHub's own templating. It needs `permissions: pull-requests: write` in the workflow. **A pull request from a fork is the exception**: GitHub gives fork PRs a read-only token by design, so commenting cannot work there. The action skips it rather than failing a contributor's PR. ## Pinning Use `one9x/pages-deploy@v1`. The `v1` tag moves as fixes land, so you get them without editing workflows; the action carries a pinned, tested CLI version, and bumping `v1` is the gated way a CLI bump reaches you. Override the CLI with `cli-version: v0.3.0` when you need a specific one. ## Other CI systems There is no wrapper for GitLab CI, CircleCI or Jenkins, and none is needed — the action is three lines of shell in a trench coat: ```console title="any CI" $ curl -fsSL https://get.one9x.com | sh $ export ONE9X_TOKEN=… $ one9x pages release ./dist --site mysite --deploy --json ✓ v8 live ``` Windows runners are not supported by the action itself, because the installer it calls is a POSIX shell script. On Windows, install the CLI with the PowerShell one-liner from [Install & sign in](/docs/install) and run the command directly. --- # Frameworks Source: https://one9x.com/docs/frameworks Which folder your framework builds to, and whether it needs --spa — the whole answer in one table. Pages serves static files, so the only two things you need to know about your framework are **which folder it builds to** and **whether that folder is a single-page app**. Everything else is the same command. | Framework | Build | Output | `--spa`? | Live | | --- | --- | --- | --- | --- | | [Vite](/docs/frameworks/vite) (React/Vue/Svelte) | `npm run build` | `dist/` | yes, with client-side routing | [demo](https://vite.one9x.app/) | | [Next.js](/docs/frameworks/nextjs) | `npm run build` | `out/` | no | [demo](https://nextjs.one9x.app/) | | [Astro](/docs/frameworks/astro) | `npm run build` | `dist/` | no | [demo](https://astro.one9x.app/) | | [Nuxt](/docs/frameworks/nuxt) | `npx nuxt generate` | `.output/public/` | no | [demo](https://nuxt.one9x.app/) | | [SvelteKit](/docs/frameworks/sveltekit) | `npm run build` | `build/` | only with an SPA fallback | [demo](https://sveltekit.one9x.app/) | | [Create React App](/docs/frameworks/create-react-app) | `npm run build` | `build/` | yes | [demo](https://create-react-app.one9x.app/) | | [Hugo](/docs/frameworks/hugo) | `hugo` | `public/` | no | [demo](https://hugo.one9x.app/) | | [Eleventy](/docs/frameworks/eleventy) | `npx @11ty/eleventy` | `_site/` | no | [demo](https://eleventy.one9x.app/) | Every demo is deployed by the template repository behind it, on every push, using the same GitHub Action you would use. Press *Use this template* and you have a repo that does the same: | | | | | | --- | --- | --- | --- | | [template-vite](https://github.com/one9x/template-vite) | [template-nextjs](https://github.com/one9x/template-nextjs) | [template-astro](https://github.com/one9x/template-astro) | [template-nuxt](https://github.com/one9x/template-nuxt) | | [template-sveltekit](https://github.com/one9x/template-sveltekit) | [template-create-react-app](https://github.com/one9x/template-create-react-app) | [template-hugo](https://github.com/one9x/template-hugo) | [template-eleventy](https://github.com/one9x/template-eleventy) | Starting a new site rather than adding a deploy to one? [Templates](/templates) has whole sites — a blog, a docs site, a landing page with a working waitlist and a portfolio — set up the same way. Built with Lovable, Bolt, v0, ChatGPT or Claude? Start at [AI tools](/docs/ai-tools) instead. Not listed? If it writes HTML, CSS and JS to a folder, it works — point `one9x pages release` at that folder. The rule of thumb for `--spa`: if the build produces **one** `index.html` and the router runs in the browser, you need it. If it produces a real HTML file per route, you don't. ## The `--spa` decision, in one line `--spa` serves `/index.html` with a `200` for any path that doesn't match a file. Without it, a client-rendered app 404s the moment someone hard-refreshes a deep route or follows a shared link — the app is fine, the router never got to run. With it on a site that has real per-route HTML files, a genuine typo'd URL returns your homepage with a `200` instead of a 404, which is worse for both readers and search engines. Details and error-page mapping are in [SPA mode & error pages](/docs/spa-and-errors). ## Things that apply to every framework **Dotfiles are skipped.** Anything beginning with a dot — `.git`, `.env`, `.DS_Store` — stays on your machine. `.well-known` is the single exception, because `assetlinks.json` and `apple-app-site-association` only work at that exact URL. This matters for Nuxt, whose output directory is `.output/public/` — the dot is on a *parent* of what you deploy. You point the release at the inner folder (`one9x pages release ./.output/public --site mysite`), so nothing is dropped. **`--exclude` globs are single-segment.** They use Go's `path.Match`, so `**` is not supported and a pattern using it matches nothing rather than erroring. Match on the file name (`'*.map'`) or exclude the directory (`admin`). **`--spa` and `--index` are the same slot.** Pass one, not both. **Pages serves paths, not routes.** A request for `/about` is served `/about/index.html` — a build that wrote `about.html` instead will 404 on every internal link while the homepage works fine. Most generators do the right thing; **Next.js and SvelteKit need a setting** (`trailingSlash`), and both framework pages say so. **Build in CI, not on the deploying machine, if you can.** [GitHub Actions](/docs/github-actions) gets you a preview URL per pull request and a publish on merge, which is the point at which the framework question stops mattering — the workflow names the folder once and nobody thinks about it again. --- # Vite Source: https://one9x.com/docs/frameworks/vite React, Vue or Svelte via Vite — build to dist/ and add --spa when routing happens in the browser. Vite builds to `dist/`. Whether you need `--spa` depends on whether your app routes in the browser. ```console title="vite" $ npm run build vite v7.0.0 building for production... ✓ built in 1.42s $ one9x pages release ./dist --site mysite --spa --deploy ✓ v1 live → https://mysite.one9x.app/ ``` ## Do you need --spa? **Yes**, if you use React Router, Vue Router, TanStack Router or any other client-side router. The build produces one `index.html` and every route is resolved in the browser; without `--spa` a hard refresh on `/settings` is a 404. **No**, if the app is a single page with no routing — a landing page, a dashboard behind one URL, a widget demo. ## Base path If you deploy under a subdirectory, set Vite's `base` to match. On a One9x site you are at the root of your own subdomain, so the default `base: '/'` is correct and you should not need to touch it — a `base` left over from a GitHub Pages project subdirectory is the usual cause of a site that loads a blank page with 404s on every asset. ## Source maps Vite emits source maps only if you ask for them, but plugins and framework presets sometimes turn them on. To keep them out of a public deploy: ```console title="no source maps" $ one9x pages release ./dist --site mysite --spa --deploy --exclude '*.map' 6 path(s) excluded ✓ v2 live ``` ## Start from a template Live, and deployed by the workflow in that repo on every push: **[vite.one9x.app](https://vite.one9x.app/)**. [`one9x/template-vite`](https://github.com/one9x/template-vite) is a minimal Vite starter with this deploy already wired up. Press **Use this template** on GitHub to get your own copy, or read it as a reference for a project you already have. Set two things in the new repo under **Settings → Secrets and variables → Actions** and it deploys on every push: | | Name | Value | | --- | --- | --- | | Variable | `ONE9X_SITE` | the site to deploy to | | Secret | `ONE9X_TOKEN` | `one9x tokens create "github actions"` | Until `ONE9X_SITE` is set the workflow is skipped rather than failed — a fresh copy should not open with a red X. ## Deploying on every push ```yaml - uses: actions/setup-node@v5 with: { node-version: 22, cache: npm } - run: npm ci - run: npm run build - uses: one9x/pages-deploy@v1 with: site: mysite dir: dist spa: true token: ${{ secrets.ONE9X_TOKEN }} deploy: ${{ github.ref == 'refs/heads/main' }} ``` The full workflow, with preview URLs on pull requests, is in [GitHub Actions](/docs/github-actions). --- # Next.js Source: https://one9x.com/docs/frameworks/nextjs Static-export Next.js builds to out/ — set output 'export', and don't pass --spa. Next.js needs one config change: **static export**. With it, `next build` writes a complete static site to `out/` and there is no Node server to run. ```js // next.config.js module.exports = { output: 'export', trailingSlash: true, // required — see below images: { unoptimized: true }, }; ``` ```console title="next.js" $ npm run build ▲ Next.js 15.0.0 ✓ Generating static pages (14/14) ✓ Exporting (3/3) $ one9x pages release ./out --site mysite --deploy ✓ v1 live → https://mysite.one9x.app/ ``` **No `--spa`.** Static export writes a real HTML file per route, so an unmatched path genuinely is a 404 and should say so. Map your own page with `--error 404:/404.html` if you have one. ## What static export rules out `output: 'export'` is not a deploy setting, it is a constraint on the app. These stop working, at build time rather than silently: - API routes and route handlers - Server-side rendering (`getServerSideProps`), middleware, server actions - Incremental static regeneration - `next/image` default loader — set `images: { unoptimized: true }`, or point `images.loader` at an external optimizer If your app needs any of those, it needs a server, and Pages is a static host — that is a real answer rather than a workaround to find. ## Set `trailingSlash: true` This one is **required**, not a preference: ```js // next.config.js module.exports = { output: 'export', images: { unoptimized: true }, trailingSlash: true, }; ``` By default the export writes `out/about.html`, and Pages serves paths rather than routes — so a request for `/about`, which is exactly what `next/link` generates, gets a 404. With `trailingSlash: true` Next writes `out/about/index.html` and the directory index resolves. The symptom if you skip it: the homepage works and every internal link 404s. ## A note on caching Next fingerprints its assets under `/_next/static//`. Because the build id changes on every build even when the file contents don't, those paths revalidate rather than being cached for a year. That costs **exactly one 304** against a content-hash ETag — the bytes are not re-sent. It is the direction our cache is deliberately built to fail in: never serve stale, occasionally revalidate something that turned out to be unchanged. Nothing to configure. ## Start from a template Live, and deployed by the workflow in that repo on every push: **[nextjs.one9x.app](https://nextjs.one9x.app/)**. [`one9x/template-nextjs`](https://github.com/one9x/template-nextjs) is a minimal Next.js starter with this deploy already wired up. Press **Use this template** on GitHub to get your own copy, or read it as a reference for a project you already have. Set two things in the new repo under **Settings → Secrets and variables → Actions** and it deploys on every push: | | Name | Value | | --- | --- | --- | | Variable | `ONE9X_SITE` | the site to deploy to | | Secret | `ONE9X_TOKEN` | `one9x tokens create "github actions"` | Until `ONE9X_SITE` is set the workflow is skipped rather than failed — a fresh copy should not open with a red X. ## Deploying on every push ```yaml - uses: actions/setup-node@v5 with: { node-version: 22, cache: npm } - run: npm ci - run: npm run build - uses: one9x/pages-deploy@v1 with: site: mysite dir: out error-pages: "404:/404.html" token: ${{ secrets.ONE9X_TOKEN }} deploy: ${{ github.ref == 'refs/heads/main' }} ``` --- # Astro Source: https://one9x.com/docs/frameworks/astro Astro's default static output builds to dist/ and needs no flags — this site is one. Astro's default output is static, so there is nothing to configure. `npm run build` writes `dist/` and you deploy it. ```console title="astro" $ npm run build building client (vite) ▶ dist/ 24 page(s) built in 1.83s $ one9x pages release ./dist --site mysite --deploy ✓ v1 live → https://mysite.one9x.app/ ``` **No `--spa`.** Astro writes a real HTML file per route. > This documentation site is an Astro build deployed to Pages on every push — > the same command, the same action, the same preview URLs. ## Set `site` for correct absolute URLs ```js // astro.config.mjs export default defineConfig({ site: 'https://mysite.one9x.app', }); ``` Astro needs `site` to build canonical URLs, and it is what `@astrojs/sitemap` uses. Set it to your custom domain if you have [linked one](/docs/domains) — sitemaps with the wrong hostname are the one mistake here that search engines act on. ## If you switched on an adapter `output: 'server'` or a deployment adapter produces a build that needs a runtime, not a folder of files. Remove the adapter and go back to the default static output. `output: 'hybrid'`/per-route `prerender` is fine for the prerendered routes and not for the rest — if any route is server-rendered, the build as a whole needs a server. ## Custom 404 Astro builds `src/pages/404.astro` to `dist/404.html`: ```console title="404" $ one9x pages release ./dist --site mysite --deploy --error 404:/404.html ✓ v2 live · custom 404 ``` ## Start from a template Live, and deployed by the workflow in that repo on every push: **[astro.one9x.app](https://astro.one9x.app/)**. [`one9x/template-astro`](https://github.com/one9x/template-astro) is a minimal Astro starter with this deploy already wired up. Press **Use this template** on GitHub to get your own copy, or read it as a reference for a project you already have. Set two things in the new repo under **Settings → Secrets and variables → Actions** and it deploys on every push: | | Name | Value | | --- | --- | --- | | Variable | `ONE9X_SITE` | the site to deploy to | | Secret | `ONE9X_TOKEN` | `one9x tokens create "github actions"` | Until `ONE9X_SITE` is set the workflow is skipped rather than failed — a fresh copy should not open with a red X. ## Deploying on every push ```yaml - uses: actions/setup-node@v5 with: { node-version: 22, cache: npm } - run: npm ci - run: npm run build - uses: one9x/pages-deploy@v1 with: site: mysite dir: dist error-pages: "404:/404.html" token: ${{ secrets.ONE9X_TOKEN }} deploy: ${{ github.ref == 'refs/heads/main' }} ``` --- # Nuxt Source: https://one9x.com/docs/frameworks/nuxt Use nuxt generate, not nuxt build — the static output lands in .output/public/. Nuxt has two build commands and only one of them produces a static site. - `nuxt build` → a **server** bundle. Needs a Node runtime. Not deployable here. - `nuxt generate` → prerendered static files in `.output/public/`. Use `generate`. ```console title="nuxt" $ npx nuxt generate ✔ Prerendered 18 routes ✔ Generated public .output/public $ one9x pages release ./.output/public --site mysite --deploy ✓ v1 live → https://mysite.one9x.app/ ``` **No `--spa`.** `generate` prerenders a real HTML file per route. ## The dot in the path is fine Dotfiles are skipped on upload, and `.output` starts with a dot — but you point the release at `.output/public`, so the dot is on a *parent* of the directory being uploaded, not on anything inside it. Nothing is dropped. If you would rather not think about it at all, set a plainer output directory: ```js // nuxt.config.ts export default defineNuxtConfig({ nitro: { output: { publicDir: 'dist' } }, }); ``` ## Routes that were not prerendered `generate` prerenders what it can crawl from your links. A route reachable only by a form submit or a dynamic parameter may be missed, and the result is a 404 on a page that works in development. List them explicitly: ```js // nuxt.config.ts export default defineNuxtConfig({ nitro: { prerender: { routes: ['/pricing', '/blog/hello-world'] }, }, }); ``` ## Server routes Anything under `server/api/` needs a running Nitro server and will not exist in a generated site. Nuxt does not fail the build over it — the routes are simply absent, which is worth knowing before you find out from a fetch that 404s. ## Start from a template Live, and deployed by the workflow in that repo on every push: **[nuxt.one9x.app](https://nuxt.one9x.app/)**. [`one9x/template-nuxt`](https://github.com/one9x/template-nuxt) is a minimal Nuxt starter with this deploy already wired up. Press **Use this template** on GitHub to get your own copy, or read it as a reference for a project you already have. Set two things in the new repo under **Settings → Secrets and variables → Actions** and it deploys on every push: | | Name | Value | | --- | --- | --- | | Variable | `ONE9X_SITE` | the site to deploy to | | Secret | `ONE9X_TOKEN` | `one9x tokens create "github actions"` | Until `ONE9X_SITE` is set the workflow is skipped rather than failed — a fresh copy should not open with a red X. ## Deploying on every push ```yaml - uses: actions/setup-node@v5 with: { node-version: 22, cache: npm } - run: npm ci - run: npx nuxt generate - uses: one9x/pages-deploy@v1 with: site: mysite dir: .output/public token: ${{ secrets.ONE9X_TOKEN }} deploy: ${{ github.ref == 'refs/heads/main' }} ``` --- # SvelteKit Source: https://one9x.com/docs/frameworks/sveltekit adapter-static is required — adapter-auto produces a server build. Output lands in build/. SvelteKit ships with `adapter-auto`, which picks a **server** target based on where it thinks it is being deployed. That produces a build Pages cannot serve. Swap it for `adapter-static`. ```console title="install the adapter" $ npm i -D @sveltejs/adapter-static ``` ```js // svelte.config.js import adapter from '@sveltejs/adapter-static'; export default { kit: { adapter: adapter(), }, }; ``` ```console title="sveltekit" $ npm run build vite v7.0.0 building SSR bundle for production... ✓ Wrote site to "build" $ one9x pages release ./build --site mysite --deploy ✓ v1 live → https://mysite.one9x.app/ ``` ## Prerendered, or SPA? This is the one decision, and it changes whether you pass `--spa`. **Prerendered (the default with `adapter-static`).** Add both of these to `src/routes/+layout.js`: ```js export const prerender = true; export const trailingSlash = 'always'; ``` `prerender` is what makes the adapter write anything at all. `trailingSlash` is **required**, not a preference: without it the adapter writes `build/about.html`, and Pages serves paths rather than routes — so a request for `/about` 404s. With it, every route becomes `build/about/index.html` and resolves. The symptom if you skip it is a homepage that works and internal links that all 404. **Do not pass `--spa`** — a genuine 404 should be a 404. **SPA fallback.** For an app whose routes can't be prerendered (anything behind a login, anything keyed on runtime data), give the adapter a fallback page: ```js adapter({ fallback: 'index.html' }) ``` Then deploy with `--spa`, so unmatched paths serve that fallback: ```console title="spa fallback" $ one9x pages release ./build --site myapp --spa --deploy ✓ v1 live · unknown routes → /index.html ``` You can mix the two — prerender the marketing routes, fall back for the app — but if any route relies on the fallback, you need `--spa`. ## Server endpoints `+page.server.js`, `+server.js` and form actions need a running server. With `adapter-static` the build fails naming the route, which is the right outcome: it tells you at build time rather than at 3am. ## Start from a template Live, and deployed by the workflow in that repo on every push: **[sveltekit.one9x.app](https://sveltekit.one9x.app/)**. [`one9x/template-sveltekit`](https://github.com/one9x/template-sveltekit) is a minimal SvelteKit starter with this deploy already wired up. Press **Use this template** on GitHub to get your own copy, or read it as a reference for a project you already have. Set two things in the new repo under **Settings → Secrets and variables → Actions** and it deploys on every push: | | Name | Value | | --- | --- | --- | | Variable | `ONE9X_SITE` | the site to deploy to | | Secret | `ONE9X_TOKEN` | `one9x tokens create "github actions"` | Until `ONE9X_SITE` is set the workflow is skipped rather than failed — a fresh copy should not open with a red X. ## Deploying on every push ```yaml - uses: actions/setup-node@v5 with: { node-version: 22, cache: npm } - run: npm ci - run: npm run build - uses: one9x/pages-deploy@v1 with: site: mysite dir: build token: ${{ secrets.ONE9X_TOKEN }} deploy: ${{ github.ref == 'refs/heads/main' }} ``` Add `spa: true` if you are using the fallback. --- # Create React App Source: https://one9x.com/docs/frameworks/create-react-app Builds to build/ and almost always needs --spa. Create React App builds to `build/`. It produces one `index.html` and routes in the browser, so it needs `--spa`. ```console title="create react app" $ npm run build Creating an optimized production build... Compiled successfully. $ one9x pages release ./build --site myapp --spa --deploy ✓ v1 live · unknown routes → /index.html → https://myapp.one9x.app/ ``` ## homepage in package.json If `package.json` has a `"homepage"` pointing at a subdirectory — a leftover from GitHub Pages — the build writes asset URLs under that path and the site loads blank with 404s on every script. On a One9x site you are at the root of your own subdomain. Remove `homepage`, or set it to `"/"`, and rebuild. ## Source maps CRA emits source maps by default, which publishes readable source alongside the bundle. Either turn them off at build time: ```console title="no source maps" $ GENERATE_SOURCEMAP=false npm run build ``` or leave them out of the release: ```console title="exclude them instead" $ one9x pages release ./build --site myapp --spa --deploy --exclude '*.map' 9 path(s) excluded ✓ v2 live ``` > Create React App is no longer maintained upstream. It still builds and still > deploys — nothing here changes — but if you are starting something new, > [Vite](/docs/frameworks/vite) is the direct replacement and the deploy command > is identical. ## Start from a template Live, and deployed by the workflow in that repo on every push: **[create-react-app.one9x.app](https://create-react-app.one9x.app/)**. [`one9x/template-create-react-app`](https://github.com/one9x/template-create-react-app) is a minimal Create React App starter with this deploy already wired up. Press **Use this template** on GitHub to get your own copy, or read it as a reference for a project you already have. Set two things in the new repo under **Settings → Secrets and variables → Actions** and it deploys on every push: | | Name | Value | | --- | --- | --- | | Variable | `ONE9X_SITE` | the site to deploy to | | Secret | `ONE9X_TOKEN` | `one9x tokens create "github actions"` | Until `ONE9X_SITE` is set the workflow is skipped rather than failed — a fresh copy should not open with a red X. ## Deploying on every push ```yaml - uses: actions/setup-node@v5 with: { node-version: 22, cache: npm } - run: npm ci - run: npm run build env: GENERATE_SOURCEMAP: 'false' - uses: one9x/pages-deploy@v1 with: site: myapp dir: build spa: true token: ${{ secrets.ONE9X_TOKEN }} deploy: ${{ github.ref == 'refs/heads/main' }} ``` --- # Hugo Source: https://one9x.com/docs/frameworks/hugo hugo writes public/ and needs no flags — set baseURL to the host you are serving from. Hugo builds to `public/` with no configuration. ```console title="hugo" $ hugo --minify Pages │ 142 Total in 214 ms $ one9x pages release ./public --site myblog --deploy ✓ v1 live → https://myblog.one9x.app/ ``` **No `--spa`.** Hugo writes a real HTML file per page. ## baseURL ```toml # hugo.toml baseURL = "https://myblog.one9x.app/" ``` Hugo bakes `baseURL` into canonical tags, feeds and sitemaps. A wrong value here is the mistake that costs you: the site looks correct and the feed points somewhere else. Set it to your [custom domain](/docs/domains) once you link one. ## Drafts and future posts `hugo` excludes drafts and future-dated content by default, which is usually what you want. In CI that default matters more than locally — a post dated tomorrow in your timezone is a post that silently does not publish. `hugo --buildFuture` includes them if you have decided you want that. ## Custom 404 Hugo builds `layouts/404.html` to `public/404.html`: ```console title="404" $ one9x pages release ./public --site myblog --deploy --error 404:/404.html ✓ v2 live · custom 404 ``` ## Start from a template Live, and deployed by the workflow in that repo on every push: **[hugo.one9x.app](https://hugo.one9x.app/)**. [`one9x/template-hugo`](https://github.com/one9x/template-hugo) is a minimal Hugo starter with this deploy already wired up. Press **Use this template** on GitHub to get your own copy, or read it as a reference for a project you already have. Set two things in the new repo under **Settings → Secrets and variables → Actions** and it deploys on every push: | | Name | Value | | --- | --- | --- | | Variable | `ONE9X_SITE` | the site to deploy to | | Secret | `ONE9X_TOKEN` | `one9x tokens create "github actions"` | Until `ONE9X_SITE` is set the workflow is skipped rather than failed — a fresh copy should not open with a red X. ## Deploying on every push ```yaml - uses: actions/checkout@v5 with: { submodules: recursive } # themes are often submodules - uses: peaceiris/actions-hugo@v3 with: { hugo-version: latest, extended: true } - run: hugo --minify - uses: one9x/pages-deploy@v1 with: site: myblog dir: public error-pages: "404:/404.html" token: ${{ secrets.ONE9X_TOKEN }} deploy: ${{ github.ref == 'refs/heads/main' }} ``` --- # Eleventy Source: https://one9x.com/docs/frameworks/eleventy Eleventy writes _site/ by default and needs no flags. Eleventy builds to `_site/` unless you tell it otherwise. ```console title="eleventy" $ npx @11ty/eleventy [11ty] Writing _site/index.html from ./src/index.njk [11ty] Wrote 64 files in 0.42 seconds $ one9x pages release ./_site --site mysite --deploy ✓ v1 live → https://mysite.one9x.app/ ``` **No `--spa`.** Eleventy writes a real HTML file per page. ## If you changed the output directory ```js // eleventy.config.js export default function (eleventyConfig) { return { dir: { input: 'src', output: 'dist' } }; } ``` Deploy whatever `dir.output` says — the flag is just a path. ## pathPrefix `pathPrefix` exists for deploying under a subdirectory. On a One9x site you are at the root of your own subdomain, so leave it at `/`. A `pathPrefix` left over from a GitHub Pages project repo produces a site whose every internal link is one level too deep. ## Custom 404 ```console title="404" $ one9x pages release ./_site --site mysite --deploy --error 404:/404.html ✓ v2 live · custom 404 ``` Eleventy's own dev server has a separate 404 setting; it does not affect the build, and the deploy flag is what decides what visitors get. ## Start from a template Live, and deployed by the workflow in that repo on every push: **[eleventy.one9x.app](https://eleventy.one9x.app/)**. [`one9x/template-eleventy`](https://github.com/one9x/template-eleventy) is a minimal Eleventy starter with this deploy already wired up. Press **Use this template** on GitHub to get your own copy, or read it as a reference for a project you already have. Set two things in the new repo under **Settings → Secrets and variables → Actions** and it deploys on every push: | | Name | Value | | --- | --- | --- | | Variable | `ONE9X_SITE` | the site to deploy to | | Secret | `ONE9X_TOKEN` | `one9x tokens create "github actions"` | Until `ONE9X_SITE` is set the workflow is skipped rather than failed — a fresh copy should not open with a red X. ## Deploying on every push ```yaml - uses: actions/setup-node@v5 with: { node-version: 22, cache: npm } - run: npm ci - run: npx @11ty/eleventy - uses: one9x/pages-deploy@v1 with: site: mysite dir: _site error-pages: "404:/404.html" token: ${{ secrets.ONE9X_TOKEN }} deploy: ${{ github.ref == 'refs/heads/main' }} ``` --- # Custom domains Source: https://one9x.com/docs/domains Put your site on your own domain — add two DNS records where you bought the domain, and HTTPS is set up and renewed for you. Every site is online straight away at its own address, like `mysite.one9x.app`. To use a domain you own instead — `example.com` or `www.example.com` — you add **two DNS records** where you bought the domain. It takes about five minutes. DNS changes are not instant. After you add the records it is normal to wait a few minutes before the domain works; that is the internet catching up, not something broken. Custom domains are a paid-plan feature — see [Certificates](#certificates) below for how many each plan includes. ## Link a domain in the dashboard 1. Open your site and go to the **Domains** tab. 2. Press **Add domain**, type the domain you want to use — for example `www.example.com` — and press **Link**. 3. The dashboard shows two records, each with a copy button: a **TXT** record that proves the domain is yours, and a **CNAME** record that points it at your site. Keep this page open. 4. In another tab, sign in where you bought the domain and add both records. [Where to add the records](#where-to-add-the-records) has a short note for the common registrars. 5. Come back and press **Check DNS**. If it isn't ready yet, wait a few minutes and press it again — pressing it more than once is fine. When the records are found, the domain shows **issuing certificate** and then **live**. The HTTPS certificate is set up for you, and renewed for you, with nothing to install. > **Using the bare domain, with no `www`?** Many registrars won't accept a > CNAME record there. [The two DNS records](#the-two-dns-records) below explains > what to add instead. ## Where to add the records Every registrar has the same three steps: open your domain's settings, find DNS management, and add a record. Add the TXT record and the CNAME record from the dashboard one at a time, copying the name and the value exactly. Many registrars add your domain to the end of the name for you. If the dashboard shows `_one9x-challenge.example.com`, you may only need to type `_one9x-challenge` — look at the records already on the page to see which form they use. Each registrar's own help page, linked below, has screenshots of these screens. ### GoDaddy Sign in to your **Domain Portfolio**, select the domain, then select **DNS**. Select **Add New Record**, choose the record type from the **Type** menu, fill in the name and value, and select **Save**. ([GoDaddy: add a CNAME record](https://www.godaddy.com/help/add-a-cname-record-19236), [add a TXT record](https://www.godaddy.com/help/add-a-txt-record-19232)) GoDaddy has no ALIAS record, and its CNAME records can't use the bare domain (`@`). For the bare domain, use the A record listed under [The two DNS records](#the-two-dns-records). ([GoDaddy: add an A record](https://www.godaddy.com/help/add-an-a-record-19238)) ### Hostinger In the Hostinger dashboard, choose **Domains** in the left sidebar, then **DNS**, and select the domain. Add each record on the DNS records page. ([Hostinger: manage DNS records](https://www.hostinger.com/support/1583249-how-to-manage-dns-records-at-hostinger/)) Hostinger supports ALIAS records, so for the bare domain add an ALIAS record instead of the A record. ([Hostinger: ALIAS records](https://www.hostinger.com/support/10067986-how-to-manage-alias-records-at-hostinger/)) DNS management here only works while the domain uses Hostinger's nameservers. If it uses another provider's, add the records there instead. ### Namecheap Select **Domain List** in the left sidebar, select **Manage** next to the domain, then open the **Advanced DNS** tab. Select **Add New Record**, choose the type, and fill in **Host** and **Value**. ([Namecheap: set up host records](https://www.namecheap.com/support/knowledgebase/article.aspx/434/2237/how-do-i-set-up-host-records-for-a-domain/)) Namecheap supports ALIAS records: for the bare domain, add an **ALIAS Record** with `@` as the host. Remove any A, AAAA, CNAME or URL Redirect record already on `@` first, or they conflict. ([Namecheap: create an ALIAS record](https://www.namecheap.com/support/knowledgebase/article.aspx/10128/2237/how-to-create-an-alias-record/)) If the **Advanced DNS** tab has no host records section, the domain uses another provider's nameservers, so add the records there instead. ### Cloudflare In the Cloudflare dashboard, open the domain, go to **DNS → Records**, and select **Add record**. Choose the type, fill in the fields, and select **Save**. ([Cloudflare: create DNS records](https://developers.cloudflare.com/dns/manage-dns-records/how-to/create-dns-records/)) **Set Proxy status to DNS only** (the grey cloud) on the CNAME record. When a record is **Proxied**, the domain shows Cloudflare's addresses instead of ours, so **Check DNS** can't find your site and the certificate is never issued. The TXT record is always DNS only. ([Cloudflare: proxy status](https://developers.cloudflare.com/dns/proxy-status/)) Cloudflare accepts a CNAME record on the bare domain (`@`) and answers it correctly on its own, so you can use the CNAME there too. ([Cloudflare: CNAME flattening](https://developers.cloudflare.com/dns/cname-flattening/)) ### Google Domains and Squarespace Domains registered with Google Domains are now managed by Squarespace. Open your domains dashboard and select the domain, then **DNS → DNS Settings**. Scroll down to **Custom Records** and select **Add record**. Squarespace asks for your password again before saving. ([Squarespace: DNS records for web hosting](https://support.squarespace.com/hc/en-us/articles/31119879125645-DNS-records-for-web-hosting)) Squarespace doesn't allow a CNAME on the bare domain (`@`), but it does support ALIAS records there. Before adding one, go to **DNS → DNSSEC** and turn **DNS Security Extensions** off, as Squarespace's instructions say. ## Details and CLI Every site is reachable immediately at its platform subdomain (`mysite.one9x.app`) over HTTPS, with no setup. To serve it on your own domain, link the domain to the site, prove you own it with a DNS record, and point your DNS at us — we issue and renew the TLS certificate automatically. `domains` is a top-level command, not part of `pages`: a domain points at a *site*, whatever product runs it. ```console title="custom domain" $ one9x domains link example.com --site mysite 1. TXT _one9x-challenge.example.com -> 9f2a… 2. CNAME example.com -> sites.one9x.app $ one9x domains verify example.com --site mysite ✓ verified · certificate issued → https://example.com/ ``` `link` prints both records with your own token filled in — copy them from there rather than from this page. ### The two DNS records Both are required. **Ownership.** A TXT record at `_one9x-challenge.example.com` holding the token `link` gave you. Only whoever controls the domain's DNS can publish it, which is what makes it proof — pointing DNS at us shows that *someone* did, not who. **Routing.** A CNAME from your domain to `sites.one9x.app`. DNS does not allow a CNAME at the apex (`example.com` with no subdomain), so there the record depends on what your DNS provider supports: | Name | Type | Value | | --- | --- | --- | | `_one9x-challenge` | TXT | the token `link` printed | | `www` (or any subdomain) | CNAME — **preferred** | `sites.one9x.app` | | `@` (apex, ALIAS support) | ALIAS / ANAME — **preferred** | `sites.one9x.app` | | `@` (apex, no ALIAS) | A — fallback | `167.104.222.9` | **Prefer ALIAS or ANAME at the apex where you can.** Cloudflare, Namecheap, Hostinger, Squarespace, Route 53 and DNSimple all support it; GoDaddy does not, which is why the A record exists. The difference matters over time: an ALIAS follows us if our edge address ever changes, while an A record is a number in your zone file that only you can update. We will give notice before ever changing it, but a record we can follow is better than a record you have to remember. Order does not matter, and neither record expires. Verification is checked against public resolvers rather than our own, so give a new record a minute to propagate before running `verify` — and if it fails, the message names what we actually saw, which is usually enough to spot a typo or a proxy that is intercepting the name. ### Certificates The certificate is issued the moment ownership is proved, loaded on the first request to your domain, and renewed for you — there's nothing to install or rotate. A domain and its www count as one, so `example.com` and `www.example.com` together use a single slot. > **Custom domains are a paid-plan feature** — Starter includes one, Pro five, > Business 25 — and the free tier serves `*.one9x.app` only. Choose a plan from > **Plan & billing** in the dashboard at any time. The slot is yours the moment > the payment clears. ### Taking a domain off a site Two commands, and the difference matters. `domains unlink example.com` stops the domain routing but you keep the name and its certificate — so linking it again later, to this site or another one, is usually instant. It stays in `domains list` as unlinked. `domains release example.com` gives the name up entirely: the record and its certificate are deleted and another account may claim it. That one can't be undone, so it asks for confirmation first. Neither takes `--site`; the owning site is looked up from the name. --- # Widgets Source: https://one9x.com/docs/widgets Drop-in features for a static site — forms and comments. Define the fields, paste the generated HTML, read submissions in the dashboard. No backend to run. Widgets are the parts of a site that normally need a backend: a contact form, a waiting list, a comment thread. Pages serves them from your own domain, so you keep a static site and still collect what a static site cannot. Every widget lives under the site's **Widgets** tab in the dashboard, each with its own switch and its own page. **This page is about forms**; [comments](/docs/comments) has its own. Waiting lists, email signups and the visitor badge are on the [roadmap](/docs/roadmap). A static site has no backend to post a form to. Pages gives every site one: define a form in the dashboard, paste the HTML it generates, and submissions land in the dashboard. Nothing is added to your build, and no third-party endpoint sees your visitors. Every plan includes a Forms plan: **Forms Free, 250 submissions a month**, on Free and Starter; Forms Plus (2,500) on Pro; Forms Pro (25,000) on Business. A bigger one is an [upgrade](/pricing) that ends and renews with your plan. The count is submissions a month across all your sites, and **over it nothing is lost**: submissions are still accepted and kept, but **locked** — the dashboard and the API hide what they say until you upgrade or the month turns. Your visitors never see the limit. Forms are **off until you turn them on.** A site that has never switched them on refuses every post, which is the right default for the large majority of sites that will never have one. ## Setting one up In the dashboard, open the site, go to **Widgets** and open **Forms**. 1. **Turn on "Accept form submissions".** Off by default. While it is off every post is refused with a 404. Turning it back off later keeps your forms and the submissions you already have — it only stops new ones. It does **not** change your site: the forms on your pages still render and the captcha widget still works, so a visitor fills one in and the submit fails. Take the form down or show a notice. 2. **Define a form.** "New form" — give it a name: lowercase letters, digits, `_` and `-`, up to 64 characters. A post to a name you have not defined is refused, so a typo fails loudly rather than collecting into nowhere. A site can have up to 20 forms. 3. **Add the fields.** Use the builder — Text, Long text, Email, URL, Number, Checkbox, Dropdown, each optionally required — or write the [JSON Schema](#defining-fields-as-json-schema) directly. The two are the same thing; the builder writes the schema for you. 4. **Leave "Require a captcha" on** unless you have a reason not to. It is on by default. It raises the cost of automated submissions; the [limits](#limits) below are what actually bounds how much anyone can send. 5. **Copy the generated HTML into your site and deploy.** The dashboard writes the markup for the fields you defined, including the captcha widget. That is the whole setup. There is no key to configure, no origin to allow-list, and nothing to install — the endpoint is already part of your site. The Forms page itself is only the switch and the list. **Each form has its own page** — click one to see its submissions as a table, a column per field in the order you defined them, with "Load more" for older ones. A field you removed from the form later keeps its column, marked *(removed)*, so an old submission still reads correctly instead of quietly losing a value. ## The HTML A form posts to a path on your own site: ```html
``` With the captcha on, the widget goes inside the form and the module goes anywhere on the page: ```html
``` The captcha is [ALTCHA](https://altcha.org), a proof-of-work check: the visitor's browser does a small amount of arithmetic instead of identifying a crosswalk. It is served from your own domain, so no third party is contacted and there is nothing to disclose in a privacy policy that was not already true. `auto="onsubmit"` is what makes it invisible. The widget shows nothing at all until Send is pressed; then it holds the submit, fetches and solves a challenge — well under a second — and sends the form itself. There is no box to tick and, because the challenge is fetched at Send, nothing sitting around waiting to expire. The widget also works on its own, without a One9x form, if you want the same check in front of something else. ### What the captcha does and does not do The challenge is valid for 10 minutes after it is fetched. With `auto="onsubmit"` it is fetched as the form is sent, so in the normal flow nothing gets near that; a stale or missing one is the `400` with `fields.altcha`. Two things are worth knowing rather than guessing at: - **`form.reset()` re-arms the widget.** It listens for the form's `reset` event and clears its solved state, so the next send solves afresh. That only works when the `` is **inside** the form being reset. - **The server does not track spent challenges.** A payload replayed inside its ten minutes is accepted again. The captcha is a cost, not an identity check, and the [rate limits](#limits) are the protection that does not depend on the client. ## With an AI tool If ChatGPT, Claude, Lovable, Bolt or v0 built your site, it probably already has a contact form — one that posts nowhere, or tells you to "connect it to a database or email service". You don't need either. Let the tool wire it to your site's form service instead: 1. **Define the form first** — **Widgets → Forms**, turn it on, **New form**, add the fields — and copy the HTML the dashboard generates. 2. **Paste that HTML into the same chat**, with a prompt like the sample below. 3. **Upload the new version** and make it live. Submit the form once yourself and check it appears under **Widgets → Forms**. Starting from scratch instead? [The One9x prompt](/docs/ai-tools/prompt) has the tool build the form this way from the start and list its fields for you. A sample prompt — adjust the wording to your site: ```text Replace the contact form on my site with the form below. Keep it where the current form is and style it to match the rest of the page, but do not rename, add or remove any name="" fields, and keep the _gotcha input, the altcha-widget element and the altcha.js script tag exactly as they are. Do not add any JavaScript that submits the form — it posts on its own. [paste the HTML from the dashboard here] ``` The field names are the contract: a post with a field the dashboard does not know is refused, so if the tool "improves" a name — `phone` to `phoneNumber` — submissions start failing. Copying the generated HTML, rather than letting the tool invent the form, is what keeps the two in step. If your site is a React or Vite project, the same prompt works: the tool puts the form in a component, and you [build it](/docs/frameworks) as usual. A form that submits through `fetch` instead of a plain post needs the JSON response described [below](#what-the-visitor-sees). ## Control fields Three field names are reserved. None of them is ever stored with the submission. | Name | What it does | | --- | --- | | `_gotcha` | A honeypot, already in the HTML the dashboard generates as ``. A person never sees it; a bot fills it in, and that submission is silently dropped — the response looks like success, because telling a bot it failed only teaches it to try again. Leave it alone. If you hand-write the form, include that exact field, keep it hidden, and never pre-fill it. | | `_next` | Where to send the visitor after a successful post. Must be a path on the same site, like `/thanks`. Anything else — another host, a full URL — is ignored and the default success page is shown instead. | | `altcha` | The captcha's proof, added by the widget. You never write this yourself. | ## Defining fields as JSON Schema The builder covers the common cases; the schema editor is there when you want something it does not express, like a `pattern`. The dialect is a **strict subset** of JSON Schema: anything not listed here is refused when you save, rather than accepted and quietly ignored. The top level is always exactly this shape: ```json { "type": "object", "properties": { }, "required": [], "additionalProperties": false } ``` Inside `properties`, each field may use: | Keyword | Applies to | Notes | | --- | --- | --- | | `type` | all | One of `string`, `number`, `integer`, `boolean`. | | `title` | all | The label the dashboard shows for the field. | | `format` | strings | `email` or `uri`. | | `enum` | strings | The allowed values — a dropdown. 1 to 100 options. | | `minLength` / `maxLength` | strings | `maxLength` cannot exceed 5000. | | `minimum` / `maximum` | numbers | Inclusive bounds. | | `pattern` | strings | Go RE2 syntax. No look-arounds and no backreferences — RE2 does not have them. | | `x-one9x-widget` | strings | `"textarea"`. A display hint for the dashboard; it changes nothing about validation. | Fields are **flat**. There are no arrays, no multi-select, no nested objects and no `$ref`. If you need several values for one thing, use several fields. ## What gets accepted A submission is validated against the schema before anything is stored, and a failure stores nothing at all — there is no partial save to clean up later. A post is refused if it contains a field you did not define, a value of the wrong type, a missing required field, or the same field twice. Values are stored **typed**: a number comes back as a number and a checkbox as `true` or `false`, not as the strings the browser happened to send. A checked checkbox is `true`; an unchecked one is absent. Marking a checkbox `required` therefore means *it must be checked*, which is exactly what you want for a consent box and a surprise if you expected "must be present". ## What the visitor sees **A plain form post** gets an HTML response: `200` and a success page reading "Thanks — your submission was received.", or a `303` redirect to `_next` when that is a same-site path. On a validation failure it gets a page listing each field that was wrong. **A `fetch` that sends `Accept: application/json`** gets JSON instead, so you can keep the visitor on the page — `200` with: ```json { "ok": true } ``` and on failure, the status says which kind of failure it was: ```json { "error": "…", "fields": { "email": "must be an email address" } } ``` | Status | Body | Meaning | | --- | --- | --- | | `400` | `"some fields were not accepted"` | `fields` maps each bad field name to its message. | | `400` | `"the verification is missing or has expired — complete it again"` | `fields.altcha`. The challenge expired or was not sent — see [what the captcha does](#what-the-captcha-does-and-does-not-do). | | `404` | `"no such form"` | Forms are off for this site, or there is no form by that name. | | `429` | `"this form is not accepting submissions right now"` | The site's daily cap. The per-visitor limit also answers `429`, with a `Retry-After` header. | ## Submitting without leaving the page The plain form above is the one that should stay in your HTML: it works with no JavaScript, and it is the version that keeps working when a script fails to load. Everything here is progressive enhancement on top of it — same form, same endpoint, same fields. Three things make it a JSON exchange rather than a page navigation: - **Send `Accept: application/json`.** Without that header you get the HTML thank-you page or the `_next` redirect, which is not what a `fetch` wants. - **Send form data, not JSON.** `new FormData(form)` (multipart) or URL-encoded (`new URLSearchParams(new FormData(form))`, or jQuery's `serialize()`). A body with `Content-Type: application/json` is **not** accepted. - **Post from the site itself.** The endpoint is same-origin only — there are no CORS headers, and a cross-origin preflight gets a `404`. `FormData` is doing quiet work here: it picks up the `_gotcha` honeypot and the `altcha` field the captcha widget writes once solved, so neither needs handling. That only holds if the `` is **inside** the `
`. `_next` is ignored for a JSON request — the page decides what to show. One line deserves explaining before you read it, because it looks removable and is not. With `auto="onsubmit"` the **first** Send belongs to the widget: it cancels that submit, solves a challenge, and re-fires the event. So the handler runs twice, and the first time there is no solved payload yet. Posting then would send the form without a captcha and collect a `400`. The guard returns early on that first pass — crucially **before** `event.preventDefault()`, so the widget is still free to cancel the submit itself. On the second pass the `altcha` field is filled, the guard falls through, and exactly one request goes out. It works whichever listener happens to be registered first. Nothing else is needed, and that is the part worth trusting rather than improving on. While it solves, the widget shows *"Verifying…"* and marks its own checkbox unchecked and required, so the browser holds any further press until it finishes — pressing Send three times in a row still sends exactly one. If the challenge cannot be fetched at all, because the visitor is offline or the portal is unreachable, the widget says *"Verification failed. Try again later."*, sends nothing, and the next Send retries. Two consequences for the example: the `catch` branch never runs for a captcha failure, because no request was made — the visitor sees the widget's own message instead. And a handler that tries to add its own "Checking…" line or call the widget's reset on error can deadlock the page; the guard as written handles every one of these cases, so leave it alone. Put together, that is one listener on the form you already have: ```html

``` The same thing with jQuery, using `serialize()` for a URL-encoded body: ```js $('#contact').on('submit', function (e) { if (this.querySelector('altcha-widget') && !new FormData(this).get('altcha')) return; e.preventDefault(); $.ajax({ url: this.action, method: 'POST', data: $(this).serialize(), headers: { Accept: 'application/json' }, }) .done(() => { this.reset(); $('#status').text('Thanks — we got your message.'); }) .fail((xhr) => { const b = xhr.responseJSON || {}; $('#status').text( b.fields ? Object.entries(b.fields).map(([f, m]) => f + ' ' + m).join('; ') : b.error || 'Could not send.' ); }); }); ``` Two details worth keeping when you adapt these. The `role="status"` on the message element is what makes the result reach a screen reader — without it the text changes silently. And the `catch` branch matters as much as the error branch: a POST that never arrives leaves the button looking ready and the visitor with no idea whether they sent anything. (It covers the POST only — a captcha that fails never gets that far.) The `form.reset()` on success is also what re-arms the captcha, so a second Send from the same page view solves afresh — see [what the captcha does](#what-the-captcha-does-and-does-not-do). The `fields` object is keyed by field name, so mapping each message onto the matching input is usually better than the single line above: ```js for (const [name, message] of Object.entries(body.fields)) { form.elements[name]?.setCustomValidity(message); } form.reportValidity(); ``` ## Limits | | | | --- | --- | | Forms per site | 20 | | Fields per submission | 50 | | Request body | 64 KiB | | Characters per value | 5,000 | | Submissions a month | Your Forms plan's, across all your sites — 250 on Forms Free. Over it they are kept but locked | | Submissions per site | 500 per rolling 24 hours, or a tenth of your Forms plan's month if that is more | | Posts per visitor | 30 per hour, per visitor IP, counted across every site on the platform | | Submissions kept | All of this month's; from before it, the newest 1,000 per site (or your Forms plan's month, if that is more) | The two caps do different jobs, and it is worth not confusing them. The 30 per hour is a limit on **one visitor** — it is platform-wide per IP, not per site or per account, and a normal person never reaches it. The 500 per 24 hours is the limit on **your site's volume** — a flood guard, not your plan. Your plan is the monthly count, and going over it locks submissions rather than refusing them. A suspended site accepts nothing. ## Automate with the API Everything the Forms page does is an HTTP API, and it is the same one the dashboard itself calls. Use it to define forms from a script, or to pull submissions into something that is not a browser tab. Authenticate with an API token from the dashboard's Tokens page: ```console title="list a site's forms" $ curl -H "Authorization: Bearer one9x_tok_..." https://portal.one9x.com/api/pages/sites/42/forms ``` `{id}` is the numeric site id throughout. | Endpoint | What it does | | --- | --- | | `GET /api/pages/sites/{id}/forms` | `{"enabled": bool, "forms": [{"name", "schema", "captcha", "updated_at"}], "usage": {"used", "quota", "plan", "resets_at", "locked"}}` — `usage` is the account's month | | `PUT /api/pages/sites/{id}/forms` | Body `{"enabled": true}` — the site switch | | `PUT /api/pages/sites/{id}/forms/{name}` | Body `{"schema": {…}, "captcha": true}` — create or replace. `captcha` defaults to `true` when omitted | | `DELETE /api/pages/sites/{id}/forms/{name}` | `204`. Its submissions are kept, but the dashboard has no page to show them on any more — the API is the only way back to them | | `GET /api/pages/sites/{id}/submissions` | `{"submissions": [{"id", "form", "data", "created_at", "locked"}], "next_cursor", "has_more", "usage"}`. A submission over this month's quota has `"locked": true` and `"data": null` until you upgrade or the month turns | | `DELETE /api/pages/sites/{id}/submissions/{sid}` | `204` | Defining a form returns `400` with the reason if the schema uses anything the [subset](#defining-fields-as-json-schema) does not allow, and `409` once the site has 20 forms. ### Reading submissions `GET …/submissions` takes `form`, `order`, `limit` and `cursor`. `order` is `desc` (newest first) by default; `limit` is 1–200, default 50; `form` narrows to one form. A bad cursor, limit, order or form name is a `400`. Pass a response's `next_cursor` back as `cursor` to get the next page. The cursor is opaque and tied to the order it was issued for — sending it with the other order is a `400`. **To sync only what is new**, page with `order=asc`, keep the last `next_cursor`, and send it again later: ```console title="poll for new submissions" $ curl -H "Authorization: Bearer one9x_tok_..." "https://portal.one9x.com/api/pages/sites/42/submissions?order=asc&cursor=$SAVED" ``` `next_cursor` always comes back, and on an empty page it is the cursor you sent, so a poller never loses its place. Paging never skips or repeats a row, even while old submissions are being pruned underneath it. The pruning is the thing to design around: this month is kept whole, but from before it only the newest 1,000 per site (or your Forms plan's month, if that is more) are kept, so poll at least monthly. A locked submission comes back with `"data": null` — poll it again after an upgrade or the 1st and it has its data. ### API rate limits These are **per account**, and separate from the per-visitor limits on posting a form. Each endpoint has its own allowance. | | | | --- | --- | | Reads (either `GET`) | 120 per minute, each | | Writes (`PUT`, `DELETE`) | 30 per minute, each | Over the limit is a `429` with a `Retry-After` header in seconds. Honour it and back off rather than retrying immediately. ## `/_one9x/` is reserved Everything under `/_one9x/` is answered by One9x rather than by your files — on the platform subdomain, on preview URLs and on your custom domains alike. That is what makes the form endpoint work on whatever host your site is being served from, with no configuration. The practical consequence is that files in a **top-level** `_one9x/` folder are left out of every deploy. The deploy itself succeeds: the drop zone says so in its summary ("N dotfiles or `_one9x/` paths left out") and the CLI drops them silently, the same way it treats dotfiles. Rename the folder if your generator produces one. Only the top level is reserved. A `_one9x` folder further down — say `docs/_one9x/x.html` — deploys and serves normally. The one case that fails loudly is a client that builds its own manifest rather than using the CLI: that gets a `400` naming the path, because a tool that constructed the manifest deliberately should be told, not quietly edited. ## What this is not - **There are no email notifications.** Submissions are read and deleted on the form's own page in the dashboard. If you need a message when one arrives, poll the [API](#automate-with-the-api). - **File uploads are not stored.** A form with a file input will post, but the file is discarded. - **It is not a database.** This month's submissions are kept whole; from before it, the newest 1,000 per site (or your Forms plan's month, if that is more). This is a contact form, not durable storage. --- # Comments Source: https://one9x.com/docs/comments A comment thread under any page of a static site — paste one snippet, moderate in the dashboard. No accounts for commenters and no third-party script. Comments puts a thread under any page of your site. Like [forms](/docs/widgets), it is served from your own domain: one snippet in your HTML, no third-party script, no tracking, and nothing for a visitor to sign up for. ## Setting it up 1. **Switch it on.** In the dashboard, open the site, go to **Widgets** and turn on **Comments**. It is off by default; while it is off the snippet shows nothing and every comment is refused. Turning it off later deletes nothing. 2. **Paste the snippet** where the thread should appear, then deploy: ```html
``` Each page gets its own thread, named by its path — `/post`, `/post/` and `/post/index.html` are the same thread. If a post moves, pin its thread to the old path so the comments move with it: ```html
``` The widget's elements all carry `o9c-` classes, and its own styles load first, so any rule your site writes for them wins. ## Moderating Open **Widgets → Comments** for the site. - **New comments wait for your approval** by default. Turn off "Hold new comments for approval" and they appear at once; you can still delete them. - **Approve, delete or reply** from the list of waiting comments, or delete from the published ones. **Delete all waiting** clears the whole queue at once — a spam wave is one click. - **Your replies carry an author badge.** Only a reply sent from the dashboard (or the [API](#automate-with-the-api)) can have one — nothing a visitor posts can. Replying to a comment that is waiting approves it. - **One level of replies.** A reply to a reply joins the same thread, under the top-level comment. - **Comments are plain text.** Links are shown as text and never made clickable, which takes the point out of posting them as spam. A visitor gives a name and the comment — no email and no account. The same invisible proof-of-work [captcha](/docs/widgets#the-html) as forms runs on every comment, and it is always on. ## Plans and the monthly count Every hosting plan includes a Comments plan, and it covers all your sites ([pricing](/pricing)): | Comments plan | Published comments a month | Included with | | --- | --- | --- | | Comments Free | 500 | Free, Starter | | Comments Plus | 5,000 | Pro | | Comments Pro | 50,000 | Business | | Comments Max | 200,000 | — | What counts is a comment going **live** on any of your sites during the calendar month (Indian time). A comment you hold and never approve does not count, and neither do your own replies. When the month's count is used, **the comment box closes** on all your sites until the 1st: existing comments stay up, visitors see *"Comments are closed for now"* instead of the form, and approving waits for the new month. Nothing is billed as overage. The Comments page in the dashboard shows the count, the reset date and this rule before you reach it. To raise the limit, choose **Upgrade** beside Comments on the **Billing** page, or pick a bigger Comments plan while renewing your hosting plan, in the same payment. On a paid hosting plan the upgrade starts at once and **ends with your plan**: you pay its price less the Comments plan your hosting includes (Comments Pro on Pro is ₹200 a month), for the days left, and it renews together with the plan — one reminder, one payment. On Free it is a month of its own. Moving up from a paid Comments plan costs the difference for the days left; a smaller one starts when the paid one ends. Nothing renews automatically, and a paid Comments plan ending takes nothing down — the count goes back to the plan your hosting includes. ## Limits | | | | --- | --- | | Name | 50 characters | | Comment | 5,000 characters | | Comments per site | 500 per rolling 24 hours, or a tenth of your Comments plan's month if that is more | | Waiting for approval | 1,000 per site — past it, a site that holds comments takes no new ones until you catch up | | Posts per visitor | 20 per hour, per visitor IP | | Thread reads per visitor | 600 per minute, per visitor IP | | Shown per page | The first 1,000 comments | ## Automate with the API The Comments page is an HTTP API, the same one the dashboard calls. Authenticate with an API token from the dashboard's Tokens page; `{id}` is the numeric site id. | Endpoint | What it does | | --- | --- | | `GET /api/pages/sites/{id}/comments` | Takes `status` (`pending` or `published`), `limit` and `cursor`. Returns the comments, the switches, and `usage`: `used`, `quota`, `plan`, `resets_at` | | `PUT /api/pages/sites/{id}/comments` | Body `{"enabled": true}` and/or `{"hold": false}` | | `POST /api/pages/sites/{id}/comments/{cid}/approve` | Publishes a waiting comment | | `POST /api/pages/sites/{id}/comments/{cid}/reply` | Body `{"name": "…", "body": "…"}` — a reply with the author badge | | `DELETE /api/pages/sites/{id}/comments/{cid}` | `204`. Deletes its replies too | | `DELETE /api/pages/sites/{id}/comments?status=pending` | Deletes every waiting comment; returns `{"deleted": n}`. Published comments are never deleted in bulk | Approving, or a reply that would publish a waiting comment, is refused with a `403` once the month's count is used. A suspended site's comments are under the same preservation hold as the rest of its content: every change is a `403`. ## What this is not - **There are no email notifications.** Waiting comments are counted on the site's Widgets tab; poll the API if you need more. - **Commenters cannot edit or delete** what they posted. Delete it for them. - **There are no commenter accounts** or sign-in. - **The thread is read once, when the page loads.** On a single-page app that changes the address without loading a new page, the thread stays on the first page's; give each route its own `data-page` and a fresh element. - **`/docs` and `/docs/index.html` share a thread.** If your site ships both an exact `/docs` file and a `/docs/index.html`, they are two pages with one thread; pin one of them with `data-page`. --- # Deploy from an agent Source: https://one9x.com/docs/agents one9x mcp is a built-in MCP server — an AI agent can deploy, roll back and list releases as tool calls, with no shell glue. A deploy is a single authorized action, so it maps cleanly to a single agent tool call. Our MCP server exposes Pages as tools an AI agent can call directly — hand it a built directory and it ships a live URL, with no shell glue. It runs through the same signed-in account as the CLI, so an agent only ever touches your sites. > **The MCP server is the CLI.** `one9x mcp` speaks MCP over stdin/stdout, and > your MCP client starts it as a local process on your own machine — there is no > endpoint to point at and nothing to sign up for separately. Install the CLI, > run `one9x login` once, and it uses that session. ## Claude Code One command registers it — `--` separates Claude's flags from the command it runs: ```console title="claude code" $ claude mcp add one9x -- one9x mcp Added stdio MCP server one9x ``` Or write the same server into a project's `.mcp.json`, which is the file the command above edits and which any MCP client can read: ```json { "mcpServers": { "one9x": { "command": "one9x", "args": ["mcp"] } } } ``` ## Cursor Add One9x to `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` in a repo (that project only) — the same block as above. ## What the agent gets ```console title="claude · mcp" tool: release dir: ./dist site: mysite deploy: true ✓ Deployed → https://mysite.one9x.app/ ``` The server exposes `release`, `deploy`, `releases_list` and `sites_list` — the same operations as the CLI, returning structured JSON rather than prose — and is published to the public MCP directories so agents can discover it. ## Troubleshooting `one9x` must be on the PATH your MCP client inherits. If the client is launched from a GUI and can't find it, use the absolute path (`which one9x`) as the `command`. On a machine where no browser login is possible, set `ONE9X_TOKEN` in the server's `env` instead of running `one9x login`. ## Reading the docs as an agent If your agent can't run processes and has to read instead, these docs are served as markdown: - [`/llms.txt`](/llms.txt) — an index of every page with a one-line summary. - [`/llms-full.txt`](/llms-full.txt) — the entire documentation set as one document, for ingesting in a single request. - `/docs/.md` — the markdown source beside every HTML page, e.g. [`/docs/cli.md`](/docs/cli.md). --- # CLI reference Source: https://one9x.com/docs/cli-reference Every one9x command and flag — login, tokens, pages create/release/deploy/diff/releases, and domains. One binary, `one9x`. Products are subcommand groups (`pages`); `sites` and `domains` are account-level nouns shared across products. Everywhere a site is named it's the `--site` flag. It takes the site's name — its subdomain — and also accepts a numeric id, a platform host, or a custom domain you have linked. | Global flag | | Description | | --- | --- | --- | | `--portal ` | optional | Portal base URL. Also `ONE9X_PORTAL`; defaults to `portal.one9x.com`. | | `--json` | optional | Emit machine-readable JSON on stdout. Progress goes to stderr. | ## one9x login / logout / whoami Authorize the CLI against your One9x account and store a token locally. Run once per machine — the login renews itself, so it doesn't expire out from under you. On a server with no browser, add `--no-browser` and approve the printed URL from any other device. In CI, skip login entirely and set `ONE9X_TOKEN` to a machine token. ## one9x tokens Machine tokens authenticate CI, cron and agents with no browser. | Command | | | --- | --- | | `tokens create ` | Mints one and prints it **once** — store it then, because it is hashed and can never be shown again. `--expires-in` chooses the lifetime, up to a year; the default is 90 days. | | `tokens extend ` | Pushes the expiry out *without* changing the secret, so CI keeps working with the value it already has. | | `tokens list` | What exists, when each was last used, when each expires. | | `tokens revoke ` | Kills one immediately. | ## one9x pages create / list / rm `pages create ` makes a new site — the name becomes `.one9x.app` and is what you pass to `--site`. `pages list` shows your sites and their status, and `pages rm --site ` deletes one, after confirming — `--yes` skips the prompt for scripts. ## one9x pages release <dir> Upload a directory as a new release. Staged unless you pass `--deploy`. | Flag | | Description | | --- | --- | --- | | `--site ` | required | Site name (subdomain), host, custom domain, or numeric id. | | `--deploy` | optional | Make this release live immediately instead of staging it. | | `--spa` | optional | Serve `/index.html` (200) for unmatched routes. | | `--index ` | optional | Serve this file for unmatched routes instead. Same slot as `--spa`. | | `--error :` | repeatable | Map an HTTP status code to a page in the build. | | `--tag ` | optional | Label the release. Tagged releases survive retention. | | `--exclude ` | repeatable | Leave matching paths out. Matched against the path relative to the directory and against the file name. | ## one9x pages deploy Make an existing release live. Uploads nothing — this is both go-live and rollback. | Flag | | Description | | --- | --- | --- | | `--site ` | required | Which site to re-point. | | `--to ` | required | Release number (`v6`), tag (`prod`), or hash prefix. `--to ""` is the previous release. | ## one9x pages diff <ref> Compare a release against a baseline, file by file. See [Reviewing a change](/docs/diff). | Flag | | Description | | --- | --- | --- | | `--site ` | required | Which site the release belongs to. | | `--against ` | `live` | Baseline to compare against. `none` skips the comparison. | | `--all` | off | Include unchanged files in the listing. | ## one9x pages releases `releases list --site ` shows a site's history, newest first, with each release's number, tag and status. `releases rm --site ` deletes one, freeing a tag slot. The live release can't be deleted. ## one9x domains Custom domains, top-level rather than under a product. | Command | | | --- | --- | | `domains link --site ` | Routes a name to a site and starts verification. | | `domains verify --site ` | Checks DNS and advances it. | | `domains list` | Every name on the account, or one site's with `--site`. | | `domains unlink ` | Stops a name routing but keeps it and its certificate. | | `domains release ` | Gives it up entirely. Asks for confirmation. | `unlink` and `release` do not take `--site` — the owning site is looked up from the name. ## one9x update Updates the `one9x` binary in place to the published version. It is the same build `get.one9x.com` serves, so this is the upgrade path on a machine where re-running the installer would be awkward. ## one9x mcp Speaks MCP over stdin/stdout so an AI agent can deploy as a tool call. See [Deploy from an agent](/docs/agents). ## one9x completion Generates a completion script for `bash`, `zsh`, `fish` or `powershell`. Beyond command and flag names, values complete against your account: `--site` offers your site names, `--to` offers that site's releases and tags, and the domain commands offer your domains. If you're logged out or offline it silently offers nothing rather than erroring into your prompt. --- # Limits Source: https://one9x.com/docs/limits Every hard number — file sizes, file counts, storage, retention, domains and deploy rate — per plan. Every number the platform enforces, in one place. Where a limit is per-plan it is listed per-plan; where it is the same for everyone it says so. ## Per plan | | Free | Starter | Pro | Business | | --- | --- | --- | --- | --- | | Sites | 3 | 10 | 25 | 100 | | Custom domains | — | 1 | 5 | 25 | | Largest single file | 10 MiB | 25 MiB | 50 MiB | 100 MiB | | Files per site | 500 | 2,000 | 20,000 | 50,000 | | Storage | 1 GiB | 5 GiB | 25 GiB | 100 GiB | | Releases kept | 5 | 20 | 50 | 100 | Plans and what they cost are on the [pricing page](/pricing). Changing plan takes effect the moment the payment clears. ## What each one actually counts **Files per site** is the number of files in one release — what the site serves. It is not cumulative across releases. **Storage** counts **distinct content** across your whole account, not the sum of your releases. Every file is stored once by its hash, so a release that changes one page adds one page's worth of bytes, and twenty releases of a 500-file site cost barely more than one. This is why keeping history is close to free, and why deleting old releases usually frees less than you would expect — the bytes are shared with the releases you kept. **Releases kept** is count-based retention on *untagged* releases, so how far you can roll back does not depend on how often you ship. Older ones age out. **Largest single file** is per plan, and Business's 100 MiB is where the ladder ends: that is the storage layer's structural ceiling, so no plan can promise a file larger than it can write. If you are pushing single files near your plan's number, that is usually video, and a video host will serve it better than a static host will. **Custom domains** count www-stripped hostnames: `example.com` and `www.example.com` together use one slot. Any other subdomain is its own. ## The same on every plan | | | | --- | --- | | Tags per site | **5**. Tagged releases never age out, so this bounds how much history one site can pin. At the cap a tagged release fails rather than quietly dropping a tag — `one9x pages releases rm` frees a slot. | | Deploys | **60 per hour** per account. Comfortably above a busy CI pipeline; it is there to bound a runaway loop. | | Site name | A DNS label: 1–63 characters, lowercase letters, digits and hyphens. | ## When you hit one The limit is checked before anything is uploaded, and the message names the number and the file: ```console title="over the file-count limit" $ one9x pages release ./dist --site mysite --deploy this deploy has 812 files, and your plan allows 500 per site ``` ```console title="a single file is too big" $ one9x pages release ./dist --site mysite --deploy "/video/intro.mp4" is 18.4 MiB, and your plan's limit is 10 MiB per file ``` ```console title="out of storage" $ one9x pages release ./dist --site mysite --deploy this deploy needs 240 MiB and your account is using 0.97 GiB of its 1 GiB — free up space by deleting old releases or sites, or upgrade the plan ``` Nothing is half-uploaded when this happens: the check runs at declare time, before the first byte moves, so a rejected deploy leaves the site exactly as it was. If a build is close to the file-count limit, `--exclude` is usually the shortest fix — source maps and a stray `node_modules` account for most of it. See [Deploy from the CLI](/docs/cli). --- # Troubleshooting Source: https://one9x.com/docs/troubleshooting The errors you are most likely to hit, what each one means, and the fix — organised by what you saw, not by what went wrong. Organised by symptom. Every message quoted here is one the CLI or the portal actually prints. ## The deploy was rejected **`--site is required (a site name or id; see one9x pages list)`** The flag is missing. `one9x pages list` shows what you have. **`no site "mysite" for this account`** — followed by the sites you do have. Usually a typo, or the site was created on a different account. Create it with `one9x pages create mysite`; a release will not create one for you, because a typo would then publish your build to a brand-new address instead of the one you meant. **`./dist contains no deployable files`** The folder is empty, does not exist, or everything in it was filtered out. Dot-paths are never uploaded — if your build writes to `.output/` or `.svelte-kit/`, point the release at the folder *inside* it. Over-broad `--exclude` patterns are the other cause. **`index "/index.html" is not in the deploy`** `--spa` (or `--index`) names a file the build did not produce, at the exact path given. Check the file is really at the root of the folder you are deploying. **`error page for 404 points at "/404.html", which is not in the deploy`** Same thing for `--error`. The path is relative to the deployed folder, so a page built to `/404/index.html` will not match `--error 404:/404.html`. **`--spa and --index are the same mapping; use one`** They are one setting. `--spa` is `--index /index.html`. **`invalid --error "404" (want :, e.g. 404:/404.html)`** The colon and the path are both required. ## A limit stopped it All three are checked before anything uploads, so nothing is half-deployed: - **`this deploy has 812 files, and your plan allows 500 per site`** - **`"/video/intro.mp4" is 18.4 MiB, and your plan's limit is 10 MiB per file`** - **`this deploy needs 240 MiB and your account is using 0.97 GiB of its 1 GiB`** `--exclude '*.map'` and excluding a stray `node_modules` fix most file-count failures. The numbers are on [Limits](/docs/limits). **`5 tagged releases already (max 5) — delete one to free a slot`** Tagged releases never age out, which is what the cap bounds. `one9x pages releases rm --site ` frees one. **`tag already in use on another release of this site`** Tags are unique per site. Remove it from the other release or pick a new name. ## Sign-in and tokens **`portal 4xx: … — run one9x update`** The binary is older than the portal will talk to. `one9x update` replaces it in place. **A pipeline that worked for months suddenly 401s.** Machine tokens last 90 days by default. `one9x tokens list` shows expiry; `one9x tokens extend ` pushes it out **without changing the secret**, so nothing stored in CI has to be updated. This is the single most common way a working deploy stops working. **`the 'token' input is empty`** (GitHub Action) The secret is missing or misnamed. Check it is set on the repository or organisation, and that an organisation secret's visibility includes this repo. ## Choosing a release **`no release matches that ref`** `--to` takes a release number (`v6`), a tag, or a version-hash prefix. `one9x pages releases list --site ` shows all three for every release. **`ref is ambiguous, use a longer prefix`** A hash prefix matched more than one release. Add characters, or use the release number. **`--to is required (a release like v6, a tag, or --to "" for the previous release)`** `--to ""` is the one you want right after a bad deploy. ## The deploy worked, the site is wrong **The homepage loads, every internal link 404s.** The build wrote `about.html` where a request for `/about` needs `about/index.html`. Pages serves paths, not routes. Next.js needs `trailingSlash: true`; SvelteKit needs `trailingSlash = 'always'` — see [Frameworks](/docs/frameworks). **A deep link 404s on hard refresh, but works when clicked.** A client-side router without `--spa`. The app is fine; the first request never reaches it. See [SPA mode](/docs/spa-and-errors). **Blank page, 404 on every script and stylesheet.** The build is emitting asset URLs under a subdirectory — a `base`, `homepage` or `pathPrefix` left over from a project-subdirectory host. On One9x you are at the root of your own subdomain, so it should be `/`. **A file you expected is missing.** Anything beginning with a dot is left on your machine. `.well-known` is the one exception, so `assetlinks.json` and `apple-app-site-association` do deploy. **The site's own address says nothing is live yet.** The site exists and the name is yours — that page is what an address with no published release shows, and it prints the command that publishes. Usually it means a release was staged rather than deployed: `one9x pages releases --site ` will show it, and `one9x pages deploy --site --to v1` puts it live. The release's own preview URL works in the meantime. **A one-line edit reports hundreds of changed files.** `one9x pages diff` against live before publishing. It usually means the build changed rather than the content — a bumped asset hash, or a folder that should have been excluded. ## Custom domains **`verify` fails right after adding the record.** Verification reads public resolvers, not ours, so give a new record a minute. The failure message names what we actually saw, which is normally enough to spot a typo or a proxy answering for the name. **The apex will not take a CNAME.** DNS does not allow one there. Use ALIAS or ANAME if your provider has it, and the A record if it does not — both are on [Custom domains](/docs/domains). **`"example.com" is already unlinked`** It is off the site but still yours. `one9x domains link` re-attaches it, usually instantly, because the certificate was kept. ## GitHub Actions **`input 'deploy' must be exactly true or false, got 'yes'`** Booleans are checked rather than coerced, because a value read as `false` would silently stage a release that was meant to go live. **`dir 'dist' does not exist`** The build step did not run, or wrote somewhere else. [Frameworks](/docs/frameworks) has the folder per framework. **The job was skipped entirely.** A workflow gated on a variable that is not set. In the [template repositories](/docs/frameworks) that is `ONE9X_SITE`, deliberately — so a fresh copy does not fail before it is configured. **No preview comment on a pull request from a fork.** GitHub gives fork pull requests a read-only token by design, so commenting cannot work there. The action skips it rather than failing a contributor's PR. ## Still stuck Every command takes `--json`, which puts a machine-readable result on stdout and progress on stderr — useful to paste when asking for help. Then [contact us](/contact). --- # How deploys work Source: https://one9x.com/docs/how-it-works Hash locally, upload only what is missing, flip one pointer — why deploys are atomic and rollback is instant. Pages uses a "git-push" style deploy: the work of figuring out what changed happens on your machine, so the network only ever carries new bytes. 1. **Hash locally.** The CLI walks your directory, computes the `SHA-256` of each file, and builds a manifest mapping every path to its content hash. 2. **Ask what's missing.** It sends the manifest up; the service replies with just the hashes it hasn't seen before. 3. **Upload only those.** The CLI uploads only the missing blobs, addressed by hash. Unchanged files — frameworks, libraries, images shared with earlier deploys — are never re-sent. 4. **Publish atomically.** Once every blob is present, the manifest is stored as the new release. Going live is a separate step: the site's active-version pointer flips to that release in one operation. That flip is the whole of both deploying and rolling back, which is why a staged release costs nothing to keep and a rollback is instant. Every file is stored once by its content and served with long-lived, `immutable` caching, so repeat visits and files shared across sites load instantly. Nothing is ever mutated in place, which is what makes deploys atomic and rollbacks free. ## Consequences worth knowing **The version id is a content hash.** It is computed over the whole manifest — every path and hash, plus the release's settings. Two releases with the same id are the same release. Re-deploying an unchanged folder uploads nothing. **A staged release is fully uploaded.** "Staged" is not "partially deployed" — every byte is already in place and the only thing missing is the pointer. That is why promoting a preview cannot fail halfway, and why the preview a reviewer approved is byte-identical to what goes live. **Deleting a release does not delete files.** Blobs are shared across releases and across sites, so removing a release removes a manifest. Storage is charged on distinct content, not on the number of releases pointing at it. **Caching is content-addressed, so there is no cache to purge.** A changed file is a different hash at a different URL; an unchanged one keeps its long-lived cache entry. There is no "clear cache" button because there is nothing a stale cache could serve. --- # Roadmap Source: https://one9x.com/docs/roadmap Designed and planned, deliberately kept out of the rest of the docs so those describe only what ships today. Everything else in these docs describes what is live right now. This page is what is designed and on the roadmap — kept separate so the documentation never describes something you cannot use. ## More widgets **Forms and comments have shipped** — see [Widgets](/docs/widgets) and [Comments](/docs/comments). Waiting lists, email signups and a visitor badge are the rest of the set: one snippet in your HTML, no backend to run, submissions in your dashboard. The endpoint shape is settled and already in use by forms — a widget posts to a relative path on your own site, so there is no third-party origin in your HTML and nothing to configure. ## Analytics Per-site request and bandwidth numbers in the dashboard, without adding a script to your pages. ## Multi-region serving Serve a site from more than one region at once — closer to your visitors, no migration. A setting you flip, not a move you make. --- If one of these is the thing standing between you and using Pages, say so — [contact us](/contact). What people actually ask for moves up this list.