Using shadcn/ui
shadcn/ui copies React components into your own source tree, built on Radix primitives
and Tailwind. A site created with aircloud new is ready for it: the React template uses Tailwind by default, and
@/ already resolves to src/ in both vite.config.js and jsconfig.json. This guide takes a site from
shadcn init to a release that verifies clean, under the product's default Content-Security-Policy.
Set it up inside the site
Run the shadcn CLI in the site directory, app/site/, never at the product root:
cd app/site
npx shadcn@latest init
init writes app/site/components.json, next to package.json. The site admits any top-level .json file there as a
tooling manifest that the build never reads, so components.json needs no exception. Two names are refused:
tsconfig.json (the site is JavaScript, and jsconfig.json already declares its paths) and any .json file
whose name starts with a dot.
Make sure components.json carries these values:
{
"tsx": false,
"rsc": false,
"tailwind": { "css": "src/styles/application.css" },
"aliases": {
"components": "@/components",
"ui": "@/components/ui",
"utils": "@/lib/utils",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
tsx: false. The site is JavaScript. Withtsxfalse, shadcn writes.jsxand.jsfiles instead of.tsxand.ts, and resolves the@/aliases throughjsconfig.json.rsc: false. A Vite site has no React Server Components.tailwind.cssis the generated stylesheet, underapp/site/src/styles/. Every.cssfile in that directory, at any depth, is admitted.- The aliases.
@/issrc/, so components land underapp/site/src/components/ui/. Nested directories underapp/site/src/components/are admitted. Thecnhelper lands insrc/lib/utils.js:src/is open to your own directories, except for its fixed entries (main.jsx,application.jsx,routes.js,layouts/,components/,helpers/,pages/andstyles/).
Add components the same way, from app/site/:
npx shadcn@latest add dialog
aircloud check
A component's unit test mirrors its path. For app/site/src/components/ui/dialog.jsx, the test goes under
test/site/components/, in the same ui/ subdirectory, as dialog.test.jsx.
Remove the CLI once you have scaffolded
shadcn init can leave the shadcn package in the site's dependencies: your stylesheet imports
shadcn/tailwind.css from it. The running site doesn't need a scaffolding tool. Once the components you
need are in place, eject it:
npx shadcn@latest eject
eject copies the content of shadcn/tailwind.css into your stylesheet in place of the import. It then removes
shadcn from package.json with your package manager (npm uninstall shadcn for an npm site), which also
rewrites package-lock.json. On the site this guide was written against, the lockfile went from 460 packages
to 168. eject asks for confirmation (--yes skips it) and cannot be undone: later shadcn updates to that
stylesheet no longer apply automatically.
If your stylesheet doesn't import shadcn/tailwind.css, eject reports "Nothing to eject" and changes
nothing. In that case, if shadcn is still in package.json, run npm uninstall shadcn yourself.
You can keep adding components after ejecting. npx shadcn@latest add runs the CLI without making it a
dependency.
The content policy needs no edit
A product site runs under the policy in config/content_security_policy.toml. Its default style_src is
'self', which admits no inline style. Radix and sonner create <style> elements from script: Radix for a
Dialog's scroll-lock, sonner for its toast styles. The platform makes those styles legal with no change to the
policy:
- A fresh nonce on every page load. The policy's
nonce_directives = ["style_src"]tells the platform which directives get a new random'nonce-…'source on each response. The same nonce is written into the page. - The nonce reaches every library style. The generated
app/site/src/main.jsxis the boot module. It holds two imports:@aircloudhq/web/csp-nonce, then./application.jsx. The first module stamps the page's nonce on every<style>element a script creates, and it runs before the application and every library it imports. Keepmain.jsxthat way, and put your providers (sonner's<Toaster />, for example) insrc/application.jsxor a layout. Code inmain.jsxcan run before the stamp, soaircloud checkrefuses it (site.boot_invalid).
Some styles stay blocked, and that is deliberate:
- a
<style>injected as markup, throughinnerHTMLorinsertAdjacentHTML; - a
styleattribute set withsetAttribute("style", …).
React's style={{ … }} prop is fine: React sets those properties through the DOM, never as an attribute
string.
When you choose components, prefer ones that ship their CSS as a file or create styles from script. Avoid
any that write <style> markup or style-attribute strings. A library that ships an inline <script> needs
'unsafe-inline' in script_src and allow_unsafe_inline_scripts = true. That lets any injected script
run, so prefer one that ships its script as a file.
Never add 'unsafe-inline' to style_src. A browser ignores 'unsafe-inline' in a directive that carries a
nonce, so aircloud check refuses the combination (csp.policy_invalid). It also refuses 'none' in a nonce
directive, a fixed 'nonce-…' source of your own, and a nonce directive that the policy doesn't declare.
To drop style_src entirely, also set nonce_directives = [].
If you write your own HTML. The build stamps the nonce into the HTML it emits through Vite's
html.cspNonce option. The generated app/site/vite.config.js sets it to a placeholder, and the platform
replaces that placeholder with the page's nonce on every response. Hand-written HTML, including a static site's
pages under app/site/public/, gets the same treatment. To nonce a <style> or <script> tag there, give it
nonce="…" with the placeholder value from a generated vite.config.js. Never bake untrusted text, such as CMS
content or user submissions, into such a file: anything in it that carries the placeholder receives the live
nonce.
Test what the user does
The generated system tests import test from @aircloudhq/testing/system. That test guards every page and
every interaction with the product's policy. A violation fails the test that caused it, whether it happens on
page load or when a dialog opens or a toast appears. The spec needs no check of its own:
// test/system/dialog.spec.ts
import { expect, test } from "@aircloudhq/testing/system";
test.use({ baseURL: process.env.AIRCLOUD_BASE_URL });
test("the confirm dialog opens", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Delete" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
});
Write one such test for each component that injects styles when it opens: dialogs, popovers, menus, selects and toasts.
Translate component strings
i18n.inline_text applies to shadcn's components exactly as it does to yours. shadcn ships English literals:
the Dialog's close button carries a hard-coded Close for screen readers, for example, which a
Portuguese-speaking user would hear in English. Run aircloud check after every shadcn add, and move each
literal it flags into a locale key read with useT():
// JSX text is flagged by i18n.inline_text
<span className="sr-only">Close</span>
// compliant
import { useT } from "@aircloudhq/web/react";
const t = useT();
<span className="sr-only">{t("components.dialog.close")}</span>
Components use explicit, full keys. Keep the dialog's keys in config/locales/components/dialog.en.yml and its
siblings for each locale. Translations covers the lookup rules.
Fonts
The generated stylesheet uses the system font stack: there's no webfont to download and nothing for the
policy to admit. If init added a font package or a font import to package.json or your stylesheet,
remove any you don't use.
To use another typeface, self-host it. Put the .woff2 files in a fonts/ directory under app/site/public/
and declare them with @font-face in a stylesheet under app/site/src/styles/:
@font-face {
font-family: "Inter";
src: url("/fonts/inter-var.woff2") format("woff2");
font-display: swap;
}
Don't load fonts from a third-party CDN. The default policy's font_src is 'self', so the browser refuses
them. Ship only the weights and character subsets you actually use.
Before each release
aircloud check runs every rule this guide relies on: the site layout, i18n.inline_text and the policy
validation. The guarded system tests then exercise the components in a real browser against the release. Run
aircloud check before aircloud release create.