Quickstart
From zero to a rendered, reactive custom element in about a minute. By the end of this page you'll have a working dev server with a counter component on a routed page.
1. Install
Two packages: rogue itself and Vite (which runs the dev server and the build). Vite is a peer dependency rather than something we bundle so you control its version.
npm install @jjordy/rogue
npm install -D vite
2. Configure Vite
Three plugins, listed in the order they should run:
-
rogue()— the JSX → custom-element compiler. Looks at every.jsx/.tsxfile and produces a hoisted template plus runtime bindings. -
rogueRouter()— scanssrc/pages/at build time and exposes a route manifest as the virtual modulevirtual:jsx-wc/routes. -
rogueSsr()— gives the dev server SSR + server-action handling sonpm run devbehaves like production. Optional for pure client-side apps; required if you haveloaderfunctions oractions.
Create vite.config.js at the project root:
import { defineConfig } from 'vite'
import { rogue, rogueRouter, rogueSsr } from '@jjordy/rogue/vite'
export default defineConfig({
plugins: [rogue(), rogueRouter(), rogueSsr()],
// Tell Vite's built-in esbuild to leave JSX alone — the rogue plugin
// owns JSX transformation. Without this, esbuild would compile JSX
// into React.createElement calls before our plugin sees it.
esbuild: { jsx: 'preserve' },
})
3. Add the HTML shell
Vite needs an index.html at the project root. It's the
entry point both in dev and in production — the file references
your client-side mount script, and Vite injects asset URLs into it
during build.
doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>My rogue apptitle>
head>
<body>
<div id="app">div>
<script type="module" src="/src/main.ts">script>
body>
html>
4. Mount the router
The mount script reads any SSR-prefetched data from
window.__JSX_WC_DATA__ (set by the server during SSR)
and hands it to the router so the first paint doesn't re-fetch.
For a pure client-side app the conditional just degrades to
mount(target, {}).
src/main.ts:
import { mount } from '@jjordy/rogue/router'
const target = document.getElementById('app')
if (!target) throw new Error('mount target #app not found')
const ssr = window.__JSX_WC_DATA__
mount(target, ssr ? {
initialData: ssr.data,
initialParams: ssr.params,
} : {})
5. Your first component
Components live in src/components/. The filename
determines the custom-element tag name:
src/components/my-counter.jsx → <my-counter>
Inside defineComponent, the destructured props become
observed attributes. Their types are inferred from
the default values (number, boolean, string, array, object), so the
browser automatically parses
as start = 5
(a number) — not "5" (a string).
src/components/my-counter.jsx:
import { defineComponent, signal } from '@jjordy/rogue'
defineComponent(({ start = 0 }) => {
const [count, setCount] = signal(start)
return (
<div>
<button onClick={() => setCount(count() - 1)}>−button>
<span>Count: {count()}span>
<button onClick={() => setCount(count() + 1)}>+button>
<style>{`
div { display: inline-flex; gap: .5rem; align-items: center }
button { width: 2rem }
`}style>
div>
)
})
A few things happen automatically here:
-
The filename becomes the tag name. No string
passed to
defineComponent— the compiler reads it from the file path. -
{ start = 0 }registersstartas an observed attribute with typenumber. The browser coerces attribute strings to that type before passing them to your function. -
The
element is hoisted at compile time into a single sharedCSSStyleSheet. It doesn't re-parse on each render and isn't allocated per instance. -
Shadow DOM gives the component scoped styles automatically —
those
divandbuttonrules don't leak to the surrounding page.
6. A page that uses it
Pages live in src/pages/. Each file becomes a route
based on its path. src/pages/index.jsx serves at
/:
export default function Home() {
return (
<div>
<h1>Helloh1>
<my-counter start={5} />
div>
)
}
Notice you didn't import the counter file. The
compiler sees (a kebab-cased tag,
which is the W3C signal for a custom element), looks under
src/components/, and adds a side-effect import for
you. The component registers itself when its module loads.
7. Run it
Add scripts to package.json:
{
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": { "@jjordy/rogue": "^0.7.0" },
"devDependencies": { "vite": "^5.4.0" }
}
npm run dev
Visit http://localhost:5173. Click the buttons. Open
DevTools — you'll see a real in the
DOM with its own shadow root. The text node displaying the count
mutates in place when you click; no surrounding markup changes.
What's next
- Conventions — the full list of filename + destructure rules the compiler honors.
- Routing — nested layouts, dynamic segments, loaders, and how navigation works.
- Forms + mutations — schema-validated server actions with progressive enhancement.
- SSR + Hydration — server rendering with real hydration (no rebuild on boot).