Troubleshooting
webjs's no-build, isomorphic-module model produces a few error signatures you have not seen in a bundled framework: a module that throws at LOAD rather than at call, a 500 from a stray backtick, a strip-time failure that points at a lint rule. This page is keyed by SYMPTOM. Find the error text or the behavior you are seeing, then read the cause and the fix. Most of these are also caught ahead of time by webjs check, so run it first.
"Cannot import X from browser code. This file is server-only"
Symptom: the page goes blank and the browser console shows an error thrown at module load, naming a .server file, before any of your code runs.
Cause: you imported a server-only utility (a .server.{js,ts} file with NO 'use server' directive) directly into a page, layout, or component. The dev server resolves that browser import to a stub whose body throws at the top level, so it fails the instant the module loads, not when you call the function. This is deliberate: it keeps the server source (your database connection, secrets, node:* usage) off the client.
Fix: never import a no-'use server' util straight into client-bound code. Use it INSIDE a 'use server' action, a route.{js,ts} handler, or middleware.{js,ts} (all server-only), and have the page call that action. A page reaches server logic through an action whose RPC stub loads safely on the client. See Server Actions. This is framework invariant 1 (the .server boundary). The no-server-import-in-browser-module check rule catches this ahead of time, on any page, layout, or component the build determines will ship to the browser (a display-only page the framework elides is not flagged). A TYPE-ONLY import type { Row } from './x.server.ts' is exempt, because the TypeScript stripper erases it before it reaches the browser, so sharing a derived row type from a .server.ts is safe and is not flagged.
A 500 in production from an html template that worked in dev
Symptom: a page renders in dev but 500s in production, or throws a cryptic JavaScript parse error near a template.
Cause: a backtick character appears inside an html`...` (or css`...`) template body, even inside a CSS or HTML comment. A nested backtick closes the tagged-template literal at JavaScript parse time, so the rest of the file is misparsed.
Fix: remove the backtick from the template body. If you need a literal backtick in rendered output, build it from a string expression (${'`'}) rather than typing it inline. This is framework invariant 9.
A 500 at strip time pointing at no-non-erasable-typescript
Symptom: a .ts file 500s with a message about TypeScript stripping and the no-non-erasable-typescript rule, or the server refuses to start naming a required Node version.
Cause: WebJs strips types with Node 24+'s built-in module.stripTypeScriptTypes, which only erases TYPES. Non-erasable syntax (an enum, a namespace with values, a constructor parameter property, a legacy decorator with emitDecoratorMetadata, or import = require) has no type-only form to strip, so it fails. The Node-version variant means you are on Node below 24, where the built-in strip and recursive fs.watch are unavailable.
Fix: set compilerOptions.erasableSyntaxOnly: true in tsconfig.json so the compiler rejects these at edit time, and use the erasable equivalents (a const object plus a union type instead of an enum, an explicit field plus assignment instead of a parameter property). Upgrade to Node 24+ for the version error. See TypeScript. This is framework invariant 10, enforced by the erasable-typescript-only and no-non-erasable-typescript check rules.
An SSR crash naming document, window, or a DOM method
Symptom: the server render throws naming a browser global or an HTMLElement method (document, window, localStorage, this.querySelector, this.classList, this.attachShadow), and the page never reaches the browser.
Cause: a component touched a genuinely browser-only global in its constructor or render(), both of which run during SSR. The SSR pipeline constructs the component and calls render() on the server, where the live DOM does not exist. (The attribute methods, closest(), and the host IDL reflections ARE backed by a server shim and do not crash; only the live-DOM surface does.)
Fix: move browser-only reads (localStorage, viewport, matchMedia, navigator.*) into connectedCallback, which runs only in the browser, then write the value to a signal to refine the first paint. Defaults for the first paint go in the constructor; server-known data comes through the page function as a prop. See Components and Progressive Enhancement. The no-browser-globals-in-render check rule catches this ahead of time.
A custom element renders but never reacts to a property change
Symptom: the component paints correctly on first load, but assigning a reactive property later does not re-render it.
Cause: you wrote a class-field initializer for a factory-declared reactive property (count: number = 0 or count = 0 alongside WebComponent({ count: Number })). Under modern class-field semantics that compiles to a define on this AFTER super(), which overwrites the framework's reactive accessor, so subsequent assignments bypass the update.
Fix: remove the class-field initializer and set the default by assigning in the constructor after super(). See Components. The reactive-props-no-class-field check rule flags this.
A component method named append, remove, or appendChild silently never runs
Symptom: a handler or helper you defined on a component (an append() click handler, a remove() method) does nothing when called, with no error thrown, and TypeScript did not complain.
Cause: WebJs instruments the native DOM mutation methods on every light-DOM host to keep the slot API live, and it installs those interceptors as own properties ON THE ELEMENT INSTANCE. An own instance property shadows a method defined on the class prototype, so the framework's interceptor wins and your same-named class method is never reached. The instrumented names are append, prepend, before, after, replaceWith, replaceChildren, remove, appendChild, insertBefore, removeChild, and replaceChild. TypeScript does not catch it because a shorter override (zero or one argument) is assignable to the native signature.
Fix: rename the member to a non-native name (appendRow(), removeItem()) and update its call sites. Only these native mutation members are affected; render() and the lifecycle hooks are meant to be overridden. The no-shadowed-native-member check rule catches this ahead of time (it flags only a real instance method, never a static member, a nested-object property, or a static shadow = true component, whose native shadow slots are not instrumented). See Components.
An error saying static properties is no longer supported
Symptom: a component throws at construction with static properties is no longer supported. Declare reactive properties via the factory instead.
Cause: the class body has a hand-written static properties = { ... } field. WebJs declares reactive properties only through the WebComponent({ ... }) base-class factory now, and the runtime throws on a direct static properties.
Fix: move the properties into the factory call (class X extends WebComponent({ count: Number })). Use the prop() helper for options (prop(Number, { reflect: true })) and set defaults in the constructor. Delete the static properties block and any matching declare fields. See Components. The no-static-properties check rule flags this ahead of the runtime throw.
A component's first paint is empty until JavaScript runs
Symptom: a component shows a blank or skeleton state on first load and fills in only after hydration, and with JavaScript disabled it stays empty.
Cause: initial data is fetched in connectedCallback or firstUpdated, neither of which runs during SSR, so the server-rendered HTML is empty by design. This breaks progressive enhancement.
Fix: fetch the data on the server in the page function and pass it down as a prop (.prop=${richObject} for a custom element, which round-trips through SSR, or an attribute for a native element). Reserve connectedCallback for genuinely browser-only refinement. See Progressive Enhancement.
A whole page gets replaced on a frame navigation
Symptom: clicking a link inside a <webjs-frame> replaces the entire document instead of just the frame (for example an auth redirect returning a login page).
Cause: the navigation response did not contain a <webjs-frame> with the requested id. Rather than silently swapping the whole document, WebJs now fires a cancelable webjs:frame-missing event and leaves the frame unchanged (with a console warning).
Fix: make sure the response for a frame-scoped navigation includes the matching frame, or listen for webjs:frame-missing on document and decide the outcome yourself (the event detail carries { frameId, url, document }; call preventDefault() to take over, for example a full navigation with navigate(detail.url)). See the <webjs-frame> section in Client Router.
A vendor module 404s after deploying under a sub-path
Symptom: the app works at the origin root but, when mounted under a sub-path (example.com/app/) behind a proxy that does not strip the prefix, the importmap and module URLs 404 and the page never hydrates.
Cause: the framework-emitted URLs (importmap targets, the boot module specifiers, the RPC endpoint) default to origin-root paths.
Fix: set "webjs": { "basePath": "/app" } in package.json. WebJs strips the prefix at ingress and prefixes every framework-emitted URL, so module resolution works under the mount. See Configuration.
A browser import of an app file returns 404
Symptom: a module the browser tries to fetch returns 404 even though the file exists.
Cause: only files reachable from a browser-bound entry (a page, layout, error, loading, not-found, or component) through the static import graph are servable. A file nothing client-side imports, a hand-rolled scripts/ helper, or a .server file's source returns 404 by construction, the same posture as a bundler manifest.
Fix: import the module from a browser-bound entry so it enters the graph, or, if it is server-only, keep it behind the .server boundary and reach it through an action. See No-Build Model.
A REST endpoint for a server action 404s
Symptom: a 'use server' action is RPC-callable from a component but curling a path returns 404.
Cause: a server action is reachable over RPC, not over an arbitrary REST path on its own. REST endpoints go through route.ts (the framework's HTTP handler).
Fix: add an app/<path>/route.ts that imports the action and calls it, or wrap it with the route() adapter from @webjsdev/server (export const POST = route(myAction)). See Server Actions.
A component's static styles have no effect (and a console warning)
Symptom: a static styles = css`...` block does not style the component at all, and the framework warns at runtime.
Cause: static styles is adopted through a shadow root, but the component is in light DOM (the default), which has no shadow root, so the stylesheet is never adopted and the framework warns.
Fix: add static shadow = true to scope the styles, or use Tailwind utilities (unique by construction), or, if you keep custom CSS in light DOM, prefix every class selector with the component tag name (framework invariant 7 for light-DOM CSS). See Styling.
A vendored package throws "X is not exported" or a missing-symbol error at runtime
Symptom: two npm packages that work together locally throw at runtime in the browser, typically a missing-export or undefined-symbol error from one package reaching into another (for example @codemirror/lint calling into a @codemirror/view that is pinned an older minor than it needs).
Cause: the importmap pins each package to one version, and one pinned package declares a dependency or peer range on another pinned package that the pinned version does not satisfy. The graph is INCOHERENT: package A needs view ^6.42.0 but the importmap pins [email protected], so a symbol A expects is absent from the older bundle. This can come from a hand-edited .webjs/vendor/importmap.json, a partial vendor pin, or a stale resolve.
Fix: run webjs doctor. The importmap-coherence check inspects the produced importmap (the live one AND the vendored .webjs/vendor/importmap.json, with the same verdict for the same dependency set) and warns naming both packages, the required range, and the pinned version. Align the versions: re-run webjs vendor pin to re-resolve a coherent set, or bump the lagging package in package.json and reinstall so the importmap pins a version every dependent accepts. See No-Build Model.
A webjs.dev.before / webjs.start.before step fails and aborts the boot
Symptom: the dev or prod server refuses to start, printing webjs dev: before-step failed (exit N): <command> (or webjs start: ...) and never serving.
Cause: a webjs.dev.before / webjs.start.before step (the scaffold ships webjs db migrate) exited non-zero. As of #550, webjs dev / webjs start run these one-shot steps to completion BEFORE serving (replacing the old predev / prestart npm hooks), and they ABORT the boot on a failure so the app never serves a stale client or schema. A bare webjs dev and npm run dev run the same steps, so this is not a "wrong command" problem.
Fix: run the failing command directly to read its real error (for the scaffold's steps, webjs db generate or webjs db migrate), fix the cause (a malformed db/schema.server.ts, an unreachable DATABASE_URL), then re-run. A local binary in a step (drizzle-kit, tailwindcss) resolves under a bare webjs dev the same way npm run resolves it (the ancestor node_modules/.bin dirs are on the step's PATH).
A 'use server' file's exports are not callable from a component
Symptom: you added 'use server' to a plain .ts file and imported one of its functions into a component, but the call fails or webjs check flags use-server-needs-extension.
Cause: the RPC boundary keys on the FILE EXTENSION, not the directive alone. A 'use server' directive in a plain .ts file is a lint violation, because the file router only treats .server.{js,ts} files as the server boundary. Without the extension the source would also be served to the browser.
Fix: rename the file to *.server.ts. The extension makes it server-only (source-protected) and the 'use server' directive makes its exports RPC-callable. See Server Actions. The use-server-needs-extension check rule catches this, and use-server-exports-callable flags a 'use server' file that exports no callable function (it registers nothing, so the RPC 404s silently).
A configured server action file with more than one function is rejected
Symptom: webjs check flags one-action-per-configured-file on a *.server.ts that exports a config (method, cache, validate, middleware) alongside two callable functions.
Cause: the HTTP-verb and caching config exports (export const method, cache, tags, invalidates, validate, middleware) attach to the ONE action in the file, so a second callable function makes the config ambiguous.
Fix: keep one callable function per configured action file (the convention is one function per action file regardless). Split the second function into its own *.server.ts. See Server Actions. The one-action-per-configured-file check rule enforces this.
A nested layout or page's <html> shell is dropped
Symptom: you wrote <!doctype> / <html> / <head> / <body> in a non-root layout or a page and the tags vanish from the output, or webjs check flags shell-in-non-root-layout.
Cause: the framework auto-emits the document shell around the whole composition, so only the ROOT layout (app/layout.ts exactly) may write its own shell. A nested shell ends up inside the framework's <body> and the HTML parser drops the stray tags.
Fix: remove the shell tags from the nested layout or page and return just the content. Move any <html lang> / <body class> customization to the root layout, which the framework respects and splices its required tags into. See Routing. The shell-in-non-root-layout check rule flags this, framework invariant 8.
An import of a symbol crashes at runtime, but the checker was green
Symptom: a named import resolves to undefined and crashes on first use (often after renaming or removing a schema table), and type-checking reports has no exported member while the correctness checker passed.
Cause: most check rules are elision-based and do not compile types, so a stale import used to slip past. Dropping the example todos table while another module still imported it is the canonical case.
Fix: add the missing export, correct the imported name, or remove the import (and prune any orphaned module). The no-missing-local-import rule now flags a named value import of a symbol a resolvable app-internal module does not export, so it is caught before runtime. It is conservative by design (only app-internal specifiers, only named value imports, and it skips a module whose exports are not fully enumerable), so a re-export barrel and a 'use server' action file resolve normally. Type-checking alongside the correctness checker stays the belt-and-suspenders habit.
Still stuck
The framework source is in node_modules/@webjsdev/ with no build step, so what you read is what runs. Grep the relevant file (the SSR pipeline in @webjsdev/server/src/ssr.js, client hydration in @webjsdev/core/src/render-client.js, the convention rules in @webjsdev/server/src/check.js). Run webjs check to surface most of the issues above before they reach the browser, and run webjs check --rules to read what each rule enforces.