Best for
- New features or refactors touching pages, routes, actions, components, data, auth, sessions, styling, or tests
- Reviewing WebJs code for correctness or framework usage
- Answering "how should this be structured in WebJs?"
webjsdev/webjs/.agents/skills/webjs/SKILL.md
Build and review WebJs applications. Use when working on WebJs app structure, pages, layouts, routes, server actions, components, signals, data and validation, auth, sessions, styling, the client router, streaming, or tests. WebJs is AI-first, web-components-first, and has no build step.
Decision brief
Use this skill for end-to-end WebJs app work. It helps you choose the right layer first, reach for the right export, and avoid the WebJs-specific mistakes that Next.js or Lit muscle memory causes. WebJs is its own framework: the component API matches Lit and the routing feels li…
Compatibility matrix
| Platform | Status | Evidence | What to check |
|---|---|---|---|
| Codex | Not declared | No explicit evidence | Portability before use |
| Claude Code | Not declared | No explicit evidence | Portability before use |
| Cursor | Not declared | No explicit evidence | Portability before use |
| Gemini CLI | Not declared | No explicit evidence | Portability before use |
Installation
The source command is displayed only when detected. A safe inspection prompt is always available so your agent can explain every action before execution.
npx skills add https://github.com/webjsdev/webjs --skill ".agents/skills/webjs"Inspect the Agent Skill "webjs" from https://github.com/webjsdev/webjs/blob/5bfa596a4e92397961addc27d52e5d00bd6f5eb9/.agents/skills/webjs/SKILL.md at commit 5bfa596a4e92397961addc27d52e5d00bd6f5eb9. List every install step, command, network request, credential, file read/write, external action, and rollback step. Explain whether it fits my task. Do not install or execute anything until I approve.
Workflow
1. Classify the change. Route contract, data model, server mutation, auth, or only UI? 2. Start from the server. Add the page/route and its server action or query before wiring interactive UI. A page render or a POST should already return correct HTML before any component hydrat…
This skill is the quick guide. When you need the full API reference for a surface, load the matching file in references/ (listed below). For even deeper framework detail, WebJs ships buildless, so the source you run IS the source you read: look in nodemodules/@webjsdev/{core,ser…
WebJs is an AI-first, web-components-first framework with no build step: source files are served as native ES modules, and TypeScript is stripped in place (Node 24+ or Bun). It runs SSR + progressive enhancement by default.
New features or refactors touching pages, routes, actions, components, data, auth, sessions, styling, or tests
Scan this BEFORE deciding how to build something, while the shape of the code is still open. WebJs ships a primitive for most of the jobs below, and the mistake to guard against is rarely choosing badly between them. It is that the primitive never comes to mind at all, so the Re…
Permission review
The documentation asks the agent to read local files, directories, or repositories.
This skill is the quick guide. When you need the full API reference for a surface, load the matching file in `references/` (listed below). For even deeper framework detail, WebJs ships buildless, so the source you run IS the source you readThe documentation includes network, browsing, or remote request actions.
| call server code from the browser | import the `'use server'` function and call it | hand-writing `fetch()` against an endpoint | `references/data-and-actions.md` | `app/features/server-actions` |Evidence record
| Signal | Value | Evidence type | Meaning |
|---|---|---|---|
| Quality score | 94/100 | Computed | Documentation, specificity, maintenance, and trust rules |
| Repository stars | 109 | Source | Repository attention, not individual Skill quality |
| Compatibility | 0 platforms | Source | Declared in the catalog source record |
| Usage guide | automated source guide | Editorial | Generated or reviewed according to the visible evidence level |
Pinned source
Use this skill for end-to-end WebJs app work. It helps you choose the right layer first, reach for the right export, and avoid the WebJs-specific mistakes that Next.js or Lit muscle memory causes. WebJs is its own framework: the component API matches Lit and the routing feels like Next, but the execution model is neither.
This skill is the quick guide. When you need the full API reference for a surface, load the matching file in references/ (listed below). For even deeper framework detail, WebJs ships buildless, so the source you run IS the source you read: look in node_modules/@webjsdev/{core,server,cli}/ (each package ships its own AGENTS.md). The complete hosted docs live at https://webjs.dev/docs.
WebJs is an AI-first, web-components-first framework with no build step: source files are served as native ES modules, and TypeScript is stripped in place (Node 24+ or Bun). It runs SSR + progressive enhancement by default.
There is no server/client component split. No RSC render tree, no Flight protocol, no "use client" boundary. Instead:
app/**/page.ts, app/**/layout.ts) run only on the server to produce HTML. They do NOT hydrate, so their own markup cannot be interactive (an @click in a page template is dropped at SSR). They still LOAD in the browser so imported components register.WebComponent custom elements) hydrate per element, islands-style. All interactivity lives here: @event, reactive property assignment, signal mutation.*.server.ts is the one server boundary. With 'use server' its exports are RPC-callable from the client (the import is rewritten to a stub); without it the file is a server-only utility whose browser import throws at load. This, not a component annotation, is how a dependency (the DB driver, secrets, node:*) is kept off the client.route.ts is a server-only HTTP handler (named GET/POST exports), the one routing file that is NOT isomorphic.Progressive enhancement is the default architecture. With JS off, content reads, <a> navigates, and a <form action=${importedAction}> submits to its server action. JS is opt-in per interactive behaviour. Never write a first paint that depends on hydration.
Islands, and why their size is a decision. A WebJs page is server-rendered HTML with small interactive components embedded in it, each hydrating on its own when the browser upgrades its tag. That is the islands model, and what makes it pay is that the sea is free: static markup a page renders costs the browser nothing, because a page never hydrates. So an island is not a unit of code organisation, it is a unit of shipped JavaScript, and it should wrap the interactive part and stop. Absorbing a page's static markup into a component to keep things tidy converts free HTML into shipped JavaScript, and it takes every display-only child down with it, because a component rendered by a component that ships can no longer be elided. Size the island to the behaviour, not to the section of the design it happens to sit in. references/components.md has the stopping rule and a worked before/after.
Scan this BEFORE deciding how to build something, while the shape of the code is still open. WebJs ships a primitive for most of the jobs below, and the mistake to guard against is rarely choosing badly between them. It is that the primitive never comes to mind at all, so the React-shaped version gets hand-rolled in its place. That version compiles, passes webjs check, and ships, so nothing catches it. The "reflex to resist" column is what the hand-rolled version usually looks like.
Rows point rather than explain. The reference is the authority on the rule, and it is the durable half: the demo lives in the scaffold gallery, which an app deletes with npm run gallery:clear once it has outgrown it.
| I need to... | Reach for | Reflex to resist | Reference | Demo |
|---|---|---|---|---|
| add a URL, static or with a dynamic segment | a file at app/<path>/page.ts, [id] for a param | registering the route in a table or config | references/routing-and-pages.md | app/features/routing |
| abandon a render because something is missing or not allowed | throw notFound() / forbidden() / unauthorized() | returning an error object and branching in the template | references/routing-and-pages.md | app/features/boundaries |
| set a page's title, description, or social preview | export const metadata or generateMetadata() | writing <head> tags in the page | references/routing-and-pages.md | app/features/metadata |
| make part of the page respond to a click or hold state | a WebComponent custom element | expecting the page's own markup to hydrate | references/components.md | app/features/components |
| render a keyed list, or swap one node when state changes | repeat() / watch() from /directives | re-rendering the component or diffing by hand | references/components.md | app/features/directives |
| get server data into a component's first paint | async render() awaiting an action | fetching in connectedCallback, which SSR never calls | references/components.md | app/features/async-render |
| call server code from the browser | import the 'use server' function and call it | hand-writing fetch() against an endpoint | references/data-and-actions.md | app/features/server-actions |
| expose JSON to a caller outside the app | route.ts with named GET / POST exports | a server action, which is the in-app path | references/routing-and-pages.md | app/features/route-handler |
| write data from a form, JS off included | <form action=${importedAction}> | an @submit handler calling fetch() | references/data-and-actions.md | app/features/forms |
| make a mutation feel instant | optimistic() | a manual try-catch that restores a cached copy | references/optimistic-ui.md | app/features/optimistic-ui |
| navigate without a full page reload | nothing, the router is already on | importing or configuring a router | references/client-router-and-streaming.md | app/features/client-router |
| cross-fade a navigation instead of snapping | the view-transition meta, via page metadata | animating the swap yourself | references/client-router-and-streaming.md | app/features/view-transitions |
| show tokens or progress as the server produces them | an action returning an async generator | polling, or a socket for a one-shot answer | references/client-router-and-streaming.md | app/features/streaming |
| change ONE element after a write | <webjs-stream> | redrawing the whole list around it | references/client-router-and-streaming.md | app/features/stream |
| paint the page before a slow region is ready | <webjs-suspense> with a fallback | blocking the whole page on the slow await | references/client-router-and-streaming.md | app/features/suspense |
| refresh one region on its own, with no navigation | <webjs-frame> | a stateful component that fetches and re-renders | references/client-router-and-streaming.md | app/features/frames |
| hold a live two-way connection | a WS() route export plus connectWS() | polling on an interval | references/client-router-and-streaming.md | app/features/websockets |
| push one update to every client on a socket path | broadcast() | every client polling for changes | references/client-router-and-streaming.md | app/features/broadcast |
| add login and a signed-in-only route | createAuth plus a redirect in the page | rolling password hashing and session cookies | references/auth-and-sessions.md | app/features/auth |
| remember something per visitor across requests | getSession() on a signed cookie | a module-level map keyed by user | references/auth-and-sessions.md | app/features/sessions |
| stop re-rendering a page identical for everyone | export const revalidate | caching by hand in a module variable | references/built-ins.md | app/features/caching |
| read config or a secret at runtime | process.env server-side, WEBJS_PUBLIC_ for the browser | importing a config module into a component | references/built-ins.md | app/features/env |
| stop one caller hammering an endpoint | the rateLimit() middleware | counting requests inside the handler | references/built-ins.md | app/features/rate-limit |
| accept an upload and serve it back | FileStore plus a streaming route | buffering the file in memory or writing to public/ | references/built-ins.md | app/features/file-storage |
| keep the app usable offline | the opt-in service worker | caching responses in localStorage | references/service-worker.md | app/features/service-worker |
| see these composed in one real feature | the todo example app | stitching the single-feature demos together | references/optimistic-ui.md | app/examples/todo |
The table above routes by the job; this one routes by the topic, for when you already know which surface you are working on. Classify the task first, then load the smallest useful reference set. Each reference starts with a "What This Covers" section; read that to confirm relevance before reading the rest. Loading more than two or three at once usually means the task is not narrowed yet.
| Task involves... | Start with |
|---|---|
| Pages, layouts, dynamic routes, route handlers, metadata, redirects, 404s | references/routing-and-pages.md |
| Writing components: what a component owns, reactive props, signals, lifecycle, light vs shadow DOM | references/components.md |
Why a component's JS was or was not downloaded, webjs elision, static interactive = true | references/components.md |
Server actions, mutations, queries, validation, the ActionResult envelope | references/data-and-actions.md |
Sessions, login flows, route protection, forbidden() / unauthorized() | references/auth-and-sessions.md |
| Tailwind, light-DOM tag-prefix rule, tokens, fixed headers, no-reflow layout | references/styling.md |
Where a repeated markup helper lives (utils/ui/ vs lib/), and fragment vs display-only component | references/styling.md |
| Client router, prefetch, frames, view transitions, Suspense streaming | references/client-router-and-streaming.md |
| Optimistic UI for a user-facing mutation | references/optimistic-ui.md |
The @webjsdev/ui component kit (a components.json is present): class helpers, tokens, add / view, the MCP ui tool | references/ui-kit.md |
TypeScript at runtime, erasable syntax, full-stack types, the derive-the-type rule (never unknown / any) | references/typescript.md |
Unit, browser, e2e tests, the handle() harness, Bun parity | references/testing.md |
Auth, caching, env vars, rate limit, file storage, the webjs config block | references/built-ins.md |
| Node vs Bun, running the app, deploying, runtime-specific differences | references/runtime.md |
| Offline support, an asset cache, the opt-in service worker | references/service-worker.md |
| Splitting a large file, or how big a module may be | references/module-structure.md |
| A pattern that feels like Next.js or Lit but might not transfer | references/muscle-memory-gotchas.md |
Common bundles:
<form> POST should already return correct HTML before any component hydrates.modules/<feature>/), promote to lib/ or components/ only when reuse is real..server.ts. The DB driver, secrets, and node:* never belong in a page, layout, or component.@event) only where the UI is genuinely interactive. A display-only component is elided from the browser. Then wrap the interactive part and STOP: the static markup around it stays in the page, where it costs nothing.export const validate on an action; the RPC and route() boundaries run it.optimistic() from @webjsdev/core).unknown or any. The row type comes from the schema (typeof todos.$inferSelect), the action's input from a named interface and its result from ActionResult<T>, the routing files from PageProps / LayoutProps / RouteHandlerContext. unknown belongs on a payload nothing has vouched for yet that the next line narrows, and on a parameter of your own helper that forwards into an html template hole. Everywhere else, including a layout's children, it is a missing type. See references/typescript.md.app/ ROUTING ONLY (thin adapters importing from modules/)
layout.ts root layout (the ONLY file that may write <html>/<head>/<body>)
page.ts /
<segment>/page.ts /<segment>
[param]/page.ts dynamic route (params.param)
<path>/route.ts HTTP handler at /<path>
error.ts loading.ts not-found.ts forbidden.ts unauthorized.ts boundaries (nearest wins)
middleware.ts root middleware
modules/<feature>/ actions/ (mutations, *.server.ts), queries/ (reads, *.server.ts),
components/ (custom elements), types.ts,
utils/ (pure; returns data, or an html fragment under utils/ui/)
lib/ lib/*.server.ts server-only infra, lib/utils/ browser-safe helpers,
lib/utils/ui.ts app-wide html fragments (lib/ui/ once they grow)
components/*.ts shared presentational custom elements (one per file)
db/*.server.ts Drizzle: schema, connection
public/* static assets, served at /public/<name>
App-internal imports use the # root alias (import { db } from '#db/connection.server.ts'), Node's native package.json imports field, not deep ../../../ relatives. A same-directory import stays relative.
.server.ts, route.ts, or middleware.ts. Never in a page, layout, or component (it crashes the browser at module load).'use server' exports are async functions returning serializer-safe values. Files without 'use server' are server-only utilities.Class.register('tag-name').@), property (.), and boolean (?) holes in html are UNQUOTED: @click=${fn}, never @click="${fn}".signal / computed from @webjsdev/core, read via signal.get() inside render(). The base-class factory WebComponent({ ... }) is only for values riding an HTML attribute or arriving via SSR hydration.render() themselves.<!doctype> / <html> / <head> / <body>.html\...`` body, even in comments (it closes the literal and 500s).erasableSyntaxOnly: true): no enum, no value namespace, no constructor parameter properties, no legacy decorators.extends WebComponent({ count: Number }). Never a static properties block, never a class-field initializer (it clobbers the reactive accessor).<form action=${importedAction}>, or a per-button <button formaction=${importedAction}>. A bound submitter is SELF-SUFFICIENT (#1307): the renderer puts formmethod="post" and formenctype on the button itself, so it needs no bound form around it and works inside any form or none. Quoted bindings, non-submit controls, <input type="submit"> (the identity needs its value, which is also its label, so use a <button>), submitter name / value / form / static formaction attributes, a .prop spelling of any of those, action=${fn} off a <form>, a bound form with method="get", a BOUND submitter's own non-post formmethod or unparseable formenctype, and a non-action function all throw. A PLAIN button's own formmethod / formenctype is a legal native override and is left alone. A page has no action export, so a bare <form method="post"> is a 405.Find the right export fast. Load the linked reference for full examples.
@webjsdev/core (browser + isomorphic)html / css tagged templates. WebComponent({ ... }) base-class factory; prop(type?, opts?) declares one reactive property. register(tag, C) / Class.register('tag').signal / computed reactive state, effect(fn) client-only reaction (returns a disposer), batch(fn) coalesced writes; render(v, el) client render.notFound() / redirect(url[, status]) control-flow throws (page/layout/action only, NOT route.ts). forbidden() / unauthorized() render the nearest boundary.Suspense({fallback, children}) page-level streaming; <webjs-suspense> component-level streaming.optimistic() optimistic UI; navigate(url) / revalidate(url?) client-router control; connectWS / richFetch.asset(path) content-hashes a public/ url so a deploy cannot serve stale bytes (href=${asset('/public/app.css')}), served immutable for a year. Page / layout / metadata route only, inside the render function. See references/built-ins.md.Metadata, PageProps<R>, LayoutProps<R>, RouteHandlerContext<R>, WebjsConfig.@webjsdev/core/server: renderToString / renderToStream (Node side).@webjsdev/core/directives: repeat, unsafeHTML (trusted only), live, keyed, guard, cache, until, watch(signal), ref / createRef, asyncAppend / asyncReplace, templateContent. Task / TaskStatus live at @webjsdev/core/task, context (createContext / ContextProvider / ContextConsumer) at /context. See references/components.md for the directive table + Task + context.@webjsdev/server (server side)createRequestHandler, cors(), route(action, opts?) REST adapter, sitemap() / sitemapIndex(), actionContext(), actionSignal(), requestId(), cache() / revalidateTag.json(v) rich responder, readBody(req), clientIp(req), no-arg headers() / cookies() / cspNonce() (client counterpart richFetch is in @webjsdev/core). See references/routing-and-pages.md.createAuth (+ Credentials / Google / GitHub), auth() / auth(req), session() + cookieSession / storeSession, getSession(req) (.get / .set / .flash / .destroy). File storage: getFileStore / diskStore / signedUrl. See references/auth-and-sessions.md + references/built-ins.md.db/*.server.ts. Auth, sessions, caching, rate limit, file storage are built in and pluggable (references/built-ins.md).page.ts (server-only fn), layout.ts (embeds children), route.ts (HTTP handler), middleware.ts, *.server.ts (server boundary), error.ts / loading.ts / not-found.ts / forbidden.ts / unauthorized.ts (boundaries), metadata routes (sitemap.ts, robots.ts, manifest.ts, icon.ts, opengraph-image.ts).
// app/about/page.ts
import { html } from '@webjsdev/core';
export default function About() {
return html`<h1>About</h1>`;
}
// app/users/[id]/page.ts
import { html } from '@webjsdev/core';
import { getUser } from '#modules/users/queries/get-user.server.ts';
export default async function User({ params }: { params: { id: string } }) {
const user = await getUser(params.id); // never import the DB directly into a page
return html`<h1>${user.name}</h1>`;
}
// modules/users/actions/update-profile.server.ts
'use server';
import { eq } from 'drizzle-orm';
import { db } from '#db/connection.server.ts';
import { users } from '#db/schema.server.ts';
export async function updateProfile(input: { id: string; name: string }) {
const name = String(input?.name || '').trim();
if (!name) return { success: false, error: 'name required', status: 400 };
const [row] = await db.update(users).set({ name }).where(eq(users.id, input.id)).returning();
return { success: true, data: row };
}
Call it from a component via a normal import (rewritten to a typed RPC stub). Never hand-write fetch().
// components/counter.ts
import { WebComponent, prop, html } from '@webjsdev/core';
class Counter extends WebComponent({ count: prop(Number) }) {
constructor() { super(); this.count = 0; }
render() {
return html`<button @click=${() => this.count++}>${this.count}</button>`;
}
}
Counter.register('my-counter');
// modules/contact/actions/send-message.server.ts
'use server';
export async function sendMessage(formData: FormData) {
const email = String(formData.get('email') || '');
if (!email) return { success: false, fieldErrors: { email: 'required' } };
return { success: true, redirect: '/thanks' };
}
// app/contact/page.ts
import { sendMessage } from '#modules/contact/actions/send-message.server.ts';
export default function Contact({ actionData }) {
return html`<form action=${sendMessage}><input name="email"></form>`;
}
Binding the action is the whole wiring: the renderer omits action (so the form posts to the page's own url), supplies method="post" and an enctype, and emits a hidden __webjs_action identity field. A form-bound action always receives the FormData.
Success is a 303 (PRG); failure re-renders the page at 422 with the result on actionData. With JS the client router applies the response in place. A submission that binds nothing is a 405, and the submission is Origin-verified like an RPC call.
Sec-Fetch-Site check on both the action RPC and the form-submit path, not a token cookie. A safe GET action is CSRF-exempt. A route.ts REST endpoint is NOT covered: authenticate every mutating endpoint, validate, rate-limit.ActionResult { success: false, error } envelope, never on a raw throw.forbidden() for an authenticated user lacking permission, unauthorized() for an unauthenticated request. Inside a 'use server' RPC action, return an ActionResult for an auth failure instead of throwing.cors() from @webjsdev/server; credentials: true REQUIRES an explicit origin allowlist, never '*'.handle() from @webjsdev/server/testing and assert on the Response.npm run test:browser) for anything touching hydration, the client router, slots, or custom-element upgrade. A unit test is necessary but NOT sufficient for a browser-facing change.npm run check and npm run typecheck pass even when a layout collapses. Static tools give no signal for a visual defect.node:crypto, the TS stripper) on both..server.ts utility (no 'use server') directly into a shipping component. Its browser stub throws at load; reach it through a 'use server' action.static properties block or a class-field initializer for reactive props instead of the WebComponent({ ... }) factory.@click="${fn}").fetch() to call your own server instead of importing the action.<form method="post"> and expecting a page action export to catch it. There is no such export; bind the action with action=${fn} or the submission is a 405.formaction=${fn} on anything that is not a submit control, or on a button carrying its own name / value. The identity IS the button's name/value pair, so both halves are spoken for.formmethod="get" or formenctype="text/plain" on a button that BINDS an action. Neither can carry that action's body, so the pair contradicts itself and throws. On a button that binds nothing it is a legal native override and is honoured.export const method = 'GET'. Form-bound actions strictly require POST (default). Binding a GET action to a form is a 405 at runtime and a webjs check error (form-action-not-a-get-action).POST. Always export export const method = 'GET' for RPC data queries so arguments ride URL params, ETags/304 caching work, and CSRF is safely bypassed.method="get" on a bound <form action=${fn}>. WebJs supplies method="post" and formenctype automatically, and a bound form declaring method="get" is REFUSED at render (a thrown error, not a warning), because a GET sends no body for the action to read.redirect() / notFound() inside a route.ts handler (uncaught 500). Return a Response there.connectedCallback. SSR does not call connectedCallback; put first-paint data in the constructor (server-known inputs) or use async render().window, document, localStorage) in the constructor or render(). It throws at SSR; do browser-only work in connectedCallback.<style> / <script> body. Use static styles or Tailwind.document listener in a page or layout, coupled by a class selector. The markup, the state, and the listener belong in one component.<body> or <html>. The router's swap range never covers the document shell, so the flag outlives the markup it described. A document-wide SETTING such as the theme is the exception, and a transient effect such as a scroll lock is released in disconnectedCallback.Frequently asked questions
Use this skill for end-to-end WebJs app work. It helps you choose the right layer first, reach for the right export, and avoid the WebJs-specific mistakes that Next.js or Lit muscle memory causes. WebJs is its own framework: the component API matches Lit and the routing feels li…
The source record exposes this install command: npx skills add https://github.com/webjsdev/webjs --skill ".agents/skills/webjs". Inspect the command and pinned source before running it.
Static rules flagged read-files, network in the source; the page lists the matching lines and excerpts.
Alternatives
oaustegard/claude-skills
Generate hierarchical _FEATURES.md files that describe what a codebase DOES from a user/consumer perspective, anchored to source symbols via tree-sitting. Supports large complex codebases through feature-driven decomposition into sub-feature files. Uses a multi-pass synthesis: orientation → detail → overview rewrite. Use when someone says "what does this do", "document features", "feature inventory", "_FEATURES.md", or needs to understand a codebase's purpose before modifying it. Complements tre
dancingteeth/unified-code-review
Risk-first code review for PRs and branch audits: blast-radius triage, agent-authored discipline (tests first, intent evidence), call-graph pincer for integration defects between modules, then structural code-judo bar. Use when reviewing PRs, auditing agent-written diffs, catching rubber-stamp green CI, or wiring bugs single-file review misses. Prefer over structure-only thermo-nuclear review alone. Do not use for unrelated coding tasks or as an always-on rule.
Postpartum-genushyacinthus29/dotnet-skills
Build long-running .NET background services with `BackgroundService`, Generic Host, graceful shutdown, configuration, logging, and deployment patterns suited to workers and daemons.
enuno/unifi-mcp-server
Specialized guide for adding new MCP tools to the UniFi MCP Server following project standards, UniFi API patterns, and test-driven development practices. Use when implementing new UniFi Network Controller features as MCP tools.