Use when building a real interactive web app to host on Artifact Studio (apps go live at <slug>.jasonv.app), not just publishing a file you already made. Covers designing it, building it as a multi-file React app from esm.sh with no build step, adding the optional KV backend, and deploying + redeploying with the artifact CLI. Use whenever the task is "build and ship an app/tool/page" and the output should be a live URL.
Scanned 9/6/2026
Install to Claude Code
npx -y skills add jpvarbed/artifact-studio-tools --skill build-artifact-app --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Build Artifact App?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/jpvarbed-build-artifact-app)More formats (shields.io, HTML) on the badges page.
---
name: build-artifact-app
description: Use when building a real interactive web app to host on Artifact Studio (apps go live at <slug>.jasonv.app), not just publishing a file you already made. Covers designing it, building it as a multi-file React app from esm.sh with no build step, adding the optional KV backend, and deploying + redeploying with the artifact CLI. Use whenever the task is "build and ship an app/tool/page" and the output should be a live URL.
---
# Build an Artifact Studio app
Design, build, and ship a real app to a live URL at `<slug>.jasonv.app`. Apps are multi-file static sites served full-page on their own origin with network access, so they load dependencies straight from a CDN. **There is no build step.** For publishing a one-off file you already made (an SVG, a Markdown page), use the `share-artifact` skill instead.
**Default to a multi-file React app from esm.sh.** A single self-contained HTML file is the exception, for something trivial. Real interactivity or more than ~100 lines means React + esm.sh.
## 1. Design first
Don't jump to code. Spend a moment on what you're building and how it should feel.
- If the ask is open-ended, sketch the idea before coding (a brainstorming skill helps if you have one).
- Make it feel built, not templated. A clear visual direction plus the small details that read as care: concentric border radii, `tabular-nums` on changing numbers, a subtle scale-on-press, staggered enter animations. (If you have `frontend-design` or `make-interfaces-feel-better` skills, use them.)
- Decide: does it need to store data across visits? If yes, you need the KV backend (step 3).
## 2. Build — no-build *or* a real build step
**Two ways, same deploy.** The host only serves static files, so pick whichever fits:
- **No-build (htm + esm.sh)** — a folder of `index.html` + JS modules that import deps from a CDN at runtime. Zero tooling, instant. Great for quick/simple apps. Mind the htm gotchas below.
- **Real build step (Vite + React + TypeScript)** — author in `.tsx` with real JSX, types, and HMR; `vite build` emits a `dist/` you deploy. **No htm gotchas.** Reach for this once the app is non-trivial or you want types. Jump to "Real build step" below, or copy `examples/guestbook`.
### No-build: multi-file React from esm.sh
A folder with `index.html` at the root, plus JS modules. Pin esm.sh versions. Each app is served at the root of its own origin (`<slug>.jasonv.app`), so relative (`./app.js`), root-absolute (`/app.js`), and CDN (`https://…`) paths all resolve — relative is safest.
`index.html`:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>My App</title>
<link rel="stylesheet" href="./styles.css" />
<script type="importmap">
{ "imports": {
"react": "https://esm.sh/react@19.0.0",
"react-dom/client": "https://esm.sh/react-dom@19.0.0/client",
"htm": "https://esm.sh/htm@3.1.1"
} }
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="./app.js"></script>
</body>
</html>
```
`app.js` uses **`htm`** for markup, not JSX. There's no transform step, so JSX would not run.
```js
import React, { useState } from "react";
import { createRoot } from "react-dom/client";
import htm from "htm";
const html = htm.bind(React.createElement);
function App() {
const [n, setN] = useState(0);
return html`<button onClick=${() => setN(n + 1)}>count ${n}</button>`;
}
createRoot(document.getElementById("root")).render(html`<${App} />`);
```
**Gotchas that will bite you:**
| Gotcha | Fix |
| --- | --- |
| `<slug>` shows up literally in output | HTML entities in `htm` template literals render as text. Use a JS expression: `${"<slug>"}`. |
| Multi-line `<pre>`/code mashes onto one line | `htm` collapses whitespace (incl. newlines) *between* elements, so one `<span>` per line gets concatenated. Render the block as one string with real `\n`: `` html`<pre>${code}</pre>` `` where `code = ["line1","line2"].join("\n")`. (String children keep their newlines; only inter-element whitespace is dropped.) |
| Styled `<button>`/`<input>` text is dark/invisible | Buttons & inputs do **not** inherit `color` — they default to the browser's (dark) UA color. Set `color: var(--ink)` (or your theme color) explicitly on every interactive element. |
| App crashes white-screen with **React error #62** | An inline `style="..."` *string*. React wants `style` as an **object**, and htm passes the attribute through verbatim. Use `style=${{ marginTop: "10px" }}`, or just a CSS class. |
| Bare `<script>` JSX | No build step. Use `htm`, or precompiled output. |
| Asset path confusion | Each app owns its origin (`<slug>.jasonv.app`), so `./foo.js`, `/foo.js`, and CDN URLs all work. Prefer relative. |
| `import "react"` fails | Pin it in the importmap (`react@19.0.0`), don't rely on bare specifiers resolving. |
| No `index.html` at root | `/` 404s. The root document must be `index.html`. |
### Real build step: Vite + React + TypeScript
When you want JSX/`.tsx`, types, and HMR, use a normal Vite app and **deploy its build output** — the htm gotchas above don't apply (real JSX handles `style` objects, whitespace, and entities correctly).
```bash
bun create vite@latest my-app -- --template react-ts # or copy examples/guestbook
cd my-app && bun install
bun run build # → dist/
artifact deploy ./dist --slug my-app --visibility public
```
- Set `base: "./"` in `vite.config.ts` so the built `index.html` references relative `./assets/...` (resolves on the app's own origin).
- Put `llms.txt` and any other root files in `public/` — Vite copies them to the `dist` root.
- The loop becomes: edit `src` → `bun run build` → `artifact deploy ./dist`. Everything else — versions, `--staging`, rollback, the KV backend below — is identical; you're just deploying a folder.
- Working example with both KV modes: **`examples/guestbook`** (real `.tsx`).
## 3. Optional KV backend
If the app stores data, provision a per-app key-value store, then read/write from the frontend (same origin):
```bash
bun run cli backend <slug> # prints a per-app data key (shown once); embed it in the app
```
```js
await fetch("/api/kv/scores/top", {
method: "PUT", headers: { "X-Data-Key": KEY }, body: JSON.stringify(value),
});
// GET → { value }; GET /api/kv/<collection> lists. No X-End-User = app-shared data.
```
**Per-user data (ART-5).** Send an `X-End-User` header and the rows become private to that visitor.
Mint a per-visitor secret once and keep it in `localStorage` — different visitors can't see each
other's data, and the same visitor gets theirs back on return:
```js
const EU = localStorage.getItem("eu") ?? (() => { const v = crypto.randomUUID(); localStorage.setItem("eu", v); return v; })();
const h = { "X-Data-Key": KEY, "X-End-User": EU };
await fetch("/api/kv/notes/draft", { method: "PUT", headers: h, body: JSON.stringify(text) });
const { value } = await (await fetch("/api/kv/notes/draft", { headers: h })).json();
```
It's capability-based, not a login: the id is a secret per device (not cross-device, not authenticated).
Drop `X-End-User` for shared state (leaderboards, global counters); include it for "my stuff."
The data key only exists after the app does, so it's a three-step bootstrap: `deploy` once, run
`backend <slug>` to get the key, embed it, then `deploy` again. After that, normal redeploys.
## 4. Add an llms.txt (do this by default)
Every app should ship an `llms.txt` — a short markdown manifest served at `<slug>.jasonv.app/llms.txt` so another **agent** can understand and drive your app without reading the source. It's the agent-native README. Write one for every app unless it's truly throwaway.
For a multi-file app, **just put `llms.txt` in the folder** — it deploys with everything else and is served at `/llms.txt` automatically (a folder file always wins). For a single-file `share`, pass `--llms ./llms.txt`. (Under the hood it's also a first-class `llmsTxt` field on the API/MCP, ≤64 KB, and it honors the app's visibility — an unlisted app's llms.txt needs the `?k=` token.)
Template:
```markdown
# <App name>
> One-sentence description of what this app does.
Two or three sentences: what a visitor or agent can do here, and how it behaves.
## Routes
- `/` — the main view
- `/<other>` — (list any additional pages)
## Data API
- KV collection `<name>`: key = `<shape>`, value = `<shape>`. Per-visitor (X-End-User) or shared.
- (omit this whole section if the app has no backend)
## Notes for agents
- How to drive it programmatically: query params, expected inputs, anything non-obvious.
```
Keep it current: update `llms.txt` whenever routes or the data model change, and redeploy.
## 5. Deploy
Credentials: set `ARTIFACT_API_KEY` (mint one at studio.artifacts.jasonv.dev → Settings; the `share-artifact` skill has the details). The API base defaults to the hosted studio.
```bash
bun run cli deploy ./my-app --slug my-app --visibility public --title "My App"
# build-step app? deploy the output folder instead: bun run cli deploy ./dist --slug my-app
```
Prints `https://my-app.jasonv.app`. Unlisted links carry a `?k=` token.
**Redeploy = a new immutable version.** Re-run `deploy` with the same `--slug`: same URL, same token, but each deploy is a saved version. Files you drop from the folder are just absent in the new version (no prune). Leave `--visibility` off to keep the current setting.
- `artifact rollback <slug> [version]` — undo a bad deploy (live points back at an earlier version).
- `artifact deploy <dir> --slug x --staging` → preview at `x--staging.jasonv.app` without touching live, then `artifact promote x`.
- `artifact versions <slug>` — list versions (live/staging marked).
The loop is build → deploy → tweak → deploy, with rollback as the safety net and `--staging` when you want to review before going live.
## 6. Verify before you hand it over
Load the live URL in a browser, confirm it renders and the console is clean, and screenshot it. Don't claim it works unseen. The most common live failure is a wrong esm.sh path or a relative-path 404, both visible in the console.
**Eyeball these — they're the gotchas above, made visible (a clean console won't catch them):**
- Console + network clean (no failed module/asset loads).
- Code/preformatted blocks render on **separate lines**, not mashed onto one (htm whitespace collapse).
- All text is **readable** — no dark-on-dark, especially button/tab/card labels (the no-`color` button trap).
- No literal `<` / `>` / `&` showing as text anywhere (the htm entity trap).
- Every interactive element (buttons, tabs, inputs) actually responds.
- `curl -s https://<slug>.jasonv.app/llms.txt` returns your manifest (not "Not found") — the agent-readable description shipped.
**Caches lie when verifying a fix.** After a redeploy, a browser (and a headless/automation browser's persistent cache) can keep serving the *old* `app.js`/`styles.css` and make a correct fix look broken. Before concluding a fix didn't land, check the source of truth: `curl -s https://<slug>.jasonv.app/app.js | grep <your-change>`. If the server has it, it's just cache — hard-refresh (Cmd/Ctrl+Shift+R).
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!