Problem
webjs mirrors Next.js file-based routing so agents trained on Next carry over their muscle memory. Auditing against Next.js 16 (verified against a local Next canary clone, FILE_TYPES in next-app-loader + the file-conventions docs) surfaced routing conventions Next has that webjs lacks or names differently. Closing them tightens the parity that is webjs's whole onboarding thesis.
This issue covers the adopt set. Deliberately NOT in scope: middleware→proxy rename (webjs keeps middleware; its semantics are Remix/Koa-style chainable per-segment middleware, documented in agent-docs/nextjs-muscle-memory-gotchas.md §8); parallel/intercepting routes (deferred, large surface); and template.{js,ts} (evaluated and dropped: lowest value in webjs because a re-inserted web component already gets a fresh connectedCallback, and it is the only gap that touches the delicate client-router keyed reconciler, so the risk/reward does not justify it).
Design / approach
Adopt four conventions from Next 16:
- async
params / searchParams. Next 15/16 made these awaitable (const { id } = await params). webjs passes plain sync objects. Make them thenable so both params.id and await params work (backward-compatible: awaiting a non-thenable already works, so this only ADDS the await form). Highest muscle-memory payoff.
forbidden() / unauthorized() throwers + forbidden.{js,ts} / unauthorized.{js,ts} boundary files. Mirror Next's forbidden()/unauthorized() control-flow throwers and their nearest-wins boundary files, extending webjs's existing notFound()/redirect() sentinel model.
global-error.{js,ts} / global-not-found.{js,ts}. Root-level boundaries. webjs's nested error/not-found cover most; add the explicit root equivalents for parity.
instrumentation.{js,ts} / instrumentation-client.{js,ts}. A boot-time startup hook (server) and load-time hook (client). webjs covers the capability via readiness.js / env.js / the onError hook; add the standardized Next names (can delegate to existing infra).
Implementation notes (for the implementing agent)
- params/searchParams: built in
packages/server/src/ssr.js (~L69, the ctx = { params, searchParams: Object.fromEntries(...), ... }). Also the page-action / route.{js,ts} handler context. Make params/searchParams thenable objects (an object with a NON-enumerable then + own enumerable keys) so sync access AND await both work. Update PageProps/LayoutProps/RouteHandlerContext types. Landmine: the then must be non-enumerable so Object.fromEntries result stays a plain-keyed object for existing params.id reads / spread / JSON / for...in.
- throwers:
packages/core/src/nav.js defines notFound()/redirect() via Symbol.for('webjs.notFound') / webjs.redirect sentinels + isNotFound/isRedirect. Add forbidden()/unauthorized() the same way (new symbols, isForbidden/isUnauthorized), export from @webjsdev/core. Catch them in the SSR page-render pipeline (packages/server/src/ssr.js) and the page-action pipeline, mapping to 403/401 and rendering the nearest boundary file.
- boundary/global files: route-tree parsing is
packages/server/src/router.js (the stem === 'page' | 'layout' | 'error' | 'loading' | 'middleware' | 'not-found' | 'route' chain). Add forbidden, unauthorized, global-error, global-not-found stems and thread them through render/boundary resolution the way not-found/error already are (nearest-wins). Also fix the latent bug that nested not-found was parsed but only the root ever rendered.
- instrumentation: boot path lives around
packages/server/src/dev.js (server start) + env-schema.js/readiness. Wire an optional app-root instrumentation.{js,ts} register export called once at boot, and instrumentation-client.{js,ts} emitted first into the client boot.
- Invariants: framework
packages/ stays plain .js + JSDoc (no .ts). Respect AGENTS.md invariants (server-only boundary, erasable TS in examples). No backward-compat shims needed beyond the thenable params (project has no users yet).
Doc + scaffold surfaces to sync
AGENTS.md (App layout + file-conventions), agent-docs/nextjs-muscle-memory-gotchas.md (§4 params), the docs site (docs/app/docs/*), and scaffold rule files. Run the webjs-doc-sync + webjs-scaffold-sync skills.
Acceptance criteria
Problem
webjs mirrors Next.js file-based routing so agents trained on Next carry over their muscle memory. Auditing against Next.js 16 (verified against a local Next canary clone,
FILE_TYPESinnext-app-loader+ the file-conventions docs) surfaced routing conventions Next has that webjs lacks or names differently. Closing them tightens the parity that is webjs's whole onboarding thesis.This issue covers the adopt set. Deliberately NOT in scope:
middleware→proxyrename (webjs keepsmiddleware; its semantics are Remix/Koa-style chainable per-segment middleware, documented inagent-docs/nextjs-muscle-memory-gotchas.md§8); parallel/intercepting routes (deferred, large surface); andtemplate.{js,ts}(evaluated and dropped: lowest value in webjs because a re-inserted web component already gets a freshconnectedCallback, and it is the only gap that touches the delicate client-router keyed reconciler, so the risk/reward does not justify it).Design / approach
Adopt four conventions from Next 16:
params/searchParams. Next 15/16 made these awaitable (const { id } = await params). webjs passes plain sync objects. Make them thenable so bothparams.idandawait paramswork (backward-compatible: awaiting a non-thenable already works, so this only ADDS the await form). Highest muscle-memory payoff.forbidden()/unauthorized()throwers +forbidden.{js,ts}/unauthorized.{js,ts}boundary files. Mirror Next'sforbidden()/unauthorized()control-flow throwers and their nearest-wins boundary files, extending webjs's existingnotFound()/redirect()sentinel model.global-error.{js,ts}/global-not-found.{js,ts}. Root-level boundaries. webjs's nestederror/not-foundcover most; add the explicit root equivalents for parity.instrumentation.{js,ts}/instrumentation-client.{js,ts}. A boot-time startup hook (server) and load-time hook (client). webjs covers the capability viareadiness.js/env.js/ theonErrorhook; add the standardized Next names (can delegate to existing infra).Implementation notes (for the implementing agent)
packages/server/src/ssr.js(~L69, thectx = { params, searchParams: Object.fromEntries(...), ... }). Also the page-action /route.{js,ts}handler context. Makeparams/searchParamsthenable objects (an object with a NON-enumerablethen+ own enumerable keys) so sync access ANDawaitboth work. UpdatePageProps/LayoutProps/RouteHandlerContexttypes. Landmine: thethenmust be non-enumerable soObject.fromEntriesresult stays a plain-keyed object for existingparams.idreads / spread / JSON /for...in.packages/core/src/nav.jsdefinesnotFound()/redirect()viaSymbol.for('webjs.notFound')/webjs.redirectsentinels +isNotFound/isRedirect. Addforbidden()/unauthorized()the same way (new symbols,isForbidden/isUnauthorized), export from@webjsdev/core. Catch them in the SSR page-render pipeline (packages/server/src/ssr.js) and the page-action pipeline, mapping to 403/401 and rendering the nearest boundary file.packages/server/src/router.js(thestem === 'page' | 'layout' | 'error' | 'loading' | 'middleware' | 'not-found' | 'route'chain). Addforbidden,unauthorized,global-error,global-not-foundstems and thread them through render/boundary resolution the waynot-found/erroralready are (nearest-wins). Also fix the latent bug that nestednot-foundwas parsed but only the root ever rendered.packages/server/src/dev.js(server start) +env-schema.js/readiness. Wire an optional app-rootinstrumentation.{js,ts}registerexport called once at boot, andinstrumentation-client.{js,ts}emitted first into the client boot.packages/stays plain.js+ JSDoc (no.ts). Respect AGENTS.md invariants (server-only boundary, erasable TS in examples). No backward-compat shims needed beyond the thenable params (project has no users yet).Doc + scaffold surfaces to sync
AGENTS.md(App layout + file-conventions),agent-docs/nextjs-muscle-memory-gotchas.md(§4 params), the docs site (docs/app/docs/*), and scaffold rule files. Run thewebjs-doc-sync+webjs-scaffold-syncskills.Acceptance criteria
await paramsandawait searchParamsboth work in pages/layouts/route handlers; syncparams.idstill works (counterfactual test that fails if thenable removed)forbidden()/unauthorized()throw sentinels caught by SSR + page-action pipelines (403/401), renderingforbidden.{js,ts}/unauthorized.{js,ts}nearest-winsglobal-error.{js,ts}/global-not-found.{js,ts}resolved at rootinstrumentation.{js,ts}runs once at boot;instrumentation-client.{js,ts}runs first on client loadwebjs checkpasses; docs + scaffold surfaces synced