Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions blog/async-render-in-web-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class UserProfile extends WebComponent({ uid: String }) {
UserProfile.register('user-profile');
```

That is it. No effect, no loading flag, no spinner, no `this.setState`. `render()` is allowed to be `async`. Writing `await` makes the function return a promise by the normal JavaScript rule, and every render path in WebJs already awaits a promise-returning `render()` automatically. There is no flag to set. A plain synchronous `render()` stays the zero-cost default, so you only pay for async where you actually reach for it. This is issue #469 if you want to read the history.
That is it. No effect, no loading flag, no spinner, no `this.setState`. `render()` is allowed to be `async`. Writing `await` makes the function return a promise by the normal JavaScript rule, and every render path in WebJs already awaits a promise-returning `render()` automatically. There is no flag to set. A plain synchronous `render()` stays the zero-cost default, so you only pay for async where you actually reach for it.


# Why this is not just useEffect with nicer syntax
Expand Down Expand Up @@ -74,9 +74,9 @@ That co-location is the other quiet win. The fetch lives in the leaf component t

Two optimizations make this cheap, and both are on by default.

A display-only async component gets **elided** (#474). If a component just fetches and renders, with no click handlers, no signals, no interactivity of any kind, then its server-rendered HTML is already the complete and final output. So WebJs drops its JavaScript module from the page entirely. The data is in the HTML, the component has nothing left to do in the browser, so nothing ships. A docs page or a content card built this way costs zero client JavaScript.
A display-only async component gets **elided**. If a component just fetches and renders, with no click handlers, no signals, no interactivity of any kind, then its server-rendered HTML is already the complete and final output. So WebJs drops its JavaScript module from the page entirely. The data is in the HTML, the component has nothing left to do in the browser, so nothing ships. A docs page or a content card built this way costs zero client JavaScript.

For a component that DOES ship (because it is also interactive), **SSR action seeding** (#472) kills the redundant re-fetch. Every `'use server'` action result computed during SSR is serialized into the page. When the component hydrates in the browser, its first RPC call reads that seed instead of hitting the network. So `getUser(this.uid)` runs once, on the server, and the client reuses the result on its first render. No hydration flicker, no wasted round trip. A later re-fetch or an argument change still goes to the server as normal.
For a component that DOES ship (because it is also interactive), **SSR action seeding** kills the redundant re-fetch. Every `'use server'` action result computed during SSR is serialized into the page. When the component hydrates in the browser, its first RPC call reads that seed instead of hitting the network. So `getUser(this.uid)` runs once, on the server, and the client reuses the result on its first render. No hydration flicker, no wasted round trip. A later re-fetch or an argument change still goes to the server as normal.


# When to reach for something else
Expand Down
2 changes: 1 addition & 1 deletion blog/bun-serve-vs-node-http.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ That is the entire tradeoff. You give up one preload-timing feature and you get

The listener choice is the headline, but a buildless server has to earn throughput in the small places too, because there is no build step ahead of time to absorb overhead. So the Bun request path got its own passes.

Brotli compression on the Bun listener runs through `node:zlib` (#518), so a Bun-served response gets the same compression a Node-served one does, no native gap there. And the per-request overhead got trimmed directly (#773): one pass removed a full request-object clone that existed only to stamp the client's IP address onto every request, and another removed an extra stream hop that every compressed response was being bridged through. Neither was large alone, but they are per-request costs, so they multiply by your traffic. On the hot path, a clone you do not need and a stream hop you can collapse are exactly the kind of thing worth cutting by hand.
Brotli compression on the Bun listener runs through `node:zlib`, so a Bun-served response gets the same compression a Node-served one does, no native gap there. And the per-request overhead got trimmed directly: one pass removed a full request-object clone that existed only to stamp the client's IP address onto every request, and another removed an extra stream hop that every compressed response was being bridged through. Neither was large alone, but they are per-request costs, so they multiply by your traffic. On the hot path, a clone you do not need and a stream hop you can collapse are exactly the kind of thing worth cutting by hand.

# You pick the runtime

Expand Down
2 changes: 1 addition & 1 deletion blog/component-suspense-streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,4 @@ One implementation note for the curious. A streamed component that uses `static

# The takeaway

WebJs blocks SSR by default so your data is in the first paint, which is the right call for fast queries and keeps the page working without JavaScript. For the one slow panel that would otherwise stall the whole first byte, wrap it in `<webjs-suspense .fallback=${...}>`. The fallback flushes immediately, the slow content streams in when it resolves, multiple boundaries fetch concurrently with no server waterfall, a throwing component stays isolated, and it all works on soft navigation too. It is plain HTML streaming, no RSC and no Flight, so the browser's own parser carries it. Fast first byte for slow data, without giving up progressive enhancement. This shipped in #470 and #471.
WebJs blocks SSR by default so your data is in the first paint, which is the right call for fast queries and keeps the page working without JavaScript. For the one slow panel that would otherwise stall the whole first byte, wrap it in `<webjs-suspense .fallback=${...}>`. The fallback flushes immediately, the slow content streams in when it resolves, multiple boundaries fetch concurrently with no server waterfall, a throwing component stays isolated, and it all works on soft navigation too. It is plain HTML streaming, no RSC and no Flight, so the browser's own parser carries it. Fast first byte for slow data, without giving up progressive enhancement.
2 changes: 1 addition & 1 deletion blog/full-stack-type-safety-no-build.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ This is why the type safety is honest and not just optimistic. The type says `Da

Server actions are the headline, but the type flow reaches the routing layer as well.

Run `webjs types` and the framework generates `.webjs/routes.d.ts`, an overlay with one entry per route in `app/`. It gives you a typed `Route` union (`navigate('/blog/anything')` is fine, `navigate('/nonexistent')` is a type error) plus per-route params. `webjs dev` regenerates it on startup and after each route rebuild, so the editor always has fresh route types (this is #258, WebJs's no-build answer to Next 15's `typedRoutes`, done through interface declaration-merging instead of a bundler).
Run `webjs types` and the framework generates `.webjs/routes.d.ts`, an overlay with one entry per route in `app/`. It gives you a typed `Route` union (`navigate('/blog/anything')` is fine, `navigate('/nonexistent')` is a type error) plus per-route params. `webjs dev` regenerates it on startup and after each route rebuild, so the editor always has fresh route types (WebJs's no-build answer to Next 15's `typedRoutes`, done through interface declaration-merging instead of a bundler).

Three exported helper types read that union. `PageProps<R>` types a page's `{ params, searchParams, url, actionData }`, `LayoutProps<R>` adds `children`, and `RouteHandlerContext<R>` types a `route.ts` handler's second argument. Pass the route literal and `params` narrows to the exact shape.

Expand Down
6 changes: 3 additions & 3 deletions blog/import-only-pages-zero-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ So a page's own module usually has nothing to do on the client. It rendered its

# What "import-only" means

Here is the subtle case, and the one this post is really about. It landed in #605 and #609.
Here is the subtle case, and the one this post is really about.

A page rarely sits there doing nothing. It imports things, in particular the interactive components it wants to render: a `<theme-toggle>`, a `<comment-box>`, a `<search-bar>`. Those components DO hydrate, so their modules genuinely need to reach the browser.

Expand All @@ -48,10 +48,10 @@ That last one is sneaky, because a class-name helper looks innocent. But if it r

The rule of thumb is short. `page.ts` and `layout.ts` should never appear in the network tab or in the boot `<script type="module">`. If one does, something in its closure is doing client-side work, and that is your signal to find the stray side effect and move it where it belongs.

You do not have to squint at the network tab to figure out which one. `webjs doctor` includes a page and layout elision advisory (#646), and a later change (#666) added advisory naming of the specific reason a given page or layout ships, so the tool tells you which client side effect pinned the module to the browser.
You do not have to squint at the network tab to figure out which one. `webjs doctor` includes a page and layout elision advisory, and it names the specific reason a given page or layout ships, so the tool tells you which client side effect pinned the module to the browser.

If you ever want everything shipped, for a debugging session or because you distrust the analysis, turn elision off with `"webjs": { "elide": false }` in the config or `WEBJS_ELIDE=0` in the environment, and every module ships as written. Dropping a page module can never change your first paint anyway, because the served HTML is identical with elision on or off.

# The takeaway

A React page component is client JavaScript you ship and hydrate. A WebJs page is not, because pages and layouts do not hydrate at all. Their functions run only on the server, so an import-only page or layout gets dropped entirely and the boot ships just the interactive component leaves it imported (#605, #609). A page ships whole only when it does client work of its own, and `webjs doctor` names the reason when it happens (#646, #666). The self-check is a glance at the network tab: if `page.ts` or `layout.ts` is in it, something in that module is doing browser work it should not. The result is a mostly-static page that ships almost no JavaScript, with not a single boundary marked by hand.
A React page component is client JavaScript you ship and hydrate. A WebJs page is not, because pages and layouts do not hydrate at all. Their functions run only on the server, so an import-only page or layout gets dropped entirely and the boot ships just the interactive component leaves it imported. A page ships whole only when it does client work of its own, and `webjs doctor` names the reason when it happens. The self-check is a glance at the network tab: if `page.ts` or `layout.ts` is in it, something in that module is doing browser work it should not. The result is a mostly-static page that ships almost no JavaScript, with not a single boundary marked by hand.
10 changes: 5 additions & 5 deletions blog/native-sqlite-node-and-bun.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ author: Vivek

You clone a project, run the install, and it sits there compiling C++ for a while. Then you upgrade to a new Node major and the same package refuses to load, because the binary it built last month was compiled against a different version. You reinstall, it recompiles, and you have lost twenty minutes to a database driver that never ran a single query. If you have used SQLite in Node, you have met better-sqlite3, and you have probably met this exact afternoon.

WebJs used to ship better-sqlite3 too. In #670 I pulled it out entirely and switched to the SQLite the runtime already includes. On Node that is `node:sqlite`, on Bun it is `bun:sqlite`. No native addon, no compile, nothing to rebuild when Node bumps a version.
WebJs used to ship better-sqlite3 too. I pulled it out entirely and switched to the SQLite the runtime already includes. On Node that is `node:sqlite`, on Bun it is `bun:sqlite`. No native addon, no compile, nothing to rebuild when Node bumps a version.


# What a native addon is, and why compiling at install is fragile
Expand Down Expand Up @@ -64,7 +64,7 @@ Nothing in that loop compiles a binary. The migration is a SQL file, and the dri

# It also works the other direction: Postgres, same code

Because Drizzle is the layer you write against, the SQLite-vs-Postgres choice is one flag at scaffold time, and the schema, queries, and actions are identical across both dialects (the dialect-parity work landed in #563).
Because Drizzle is the layer you write against, the SQLite-vs-Postgres choice is one flag at scaffold time, and the schema, queries, and actions are identical across both dialects.

```sh
webjs create my-app --db postgres
Expand All @@ -75,11 +75,11 @@ SQLite is the default because a new app then runs with zero setup, no server to

# The payoff shows up hardest in Docker

The install-time compile is annoying on your laptop and genuinely expensive in a container image, where you would otherwise ship a C++ toolchain just so a database driver could build. Dropping the native addon is what let the Bun scaffold use a pure `oven/bun:1` Dockerfile (#595, #596) with no Node in it at all.
The install-time compile is annoying on your laptop and genuinely expensive in a container image, where you would otherwise ship a C++ toolchain just so a database driver could build. Dropping the native addon is what let the Bun scaffold use a pure `oven/bun:1` Dockerfile with no Node in it at all.

That only works because two things are true at once. SQLite is built into Bun, so there is nothing to compile, and `webjs db migrate` became npx-free in #570, so the migration step needs no Node either. Remove the native addon and remove the npx dependency, and the image no longer needs a Node runtime or a compiler toolchain sitting in it. It is a smaller image that builds faster and has less to go wrong.
That only works because two things are true at once. SQLite is built into Bun, so there is nothing to compile, and `webjs db migrate` became npx-free, so the migration step needs no Node either. Remove the native addon and remove the npx dependency, and the image no longer needs a Node runtime or a compiler toolchain sitting in it. It is a smaller image that builds faster and has less to go wrong.


# The takeaway

SQLite in Node used to mean a native addon that compiled C++ at install time, with node-gyp failures, prebuilt-binary gaps, and a forced recompile on every Node major. WebJs dropped better-sqlite3 (#670) and uses the runtime's own SQLite instead, `node:sqlite` on Node 24+ and `bun:sqlite` on Bun, so there is no compile step and no binary pinned to your runtime version. Drizzle sits on top and keeps the same code across SQLite and Postgres. The lesson generalizes past this one driver. When the platform grows a capability you were pulling in a fragile dependency for, delete the dependency and use the platform.
SQLite in Node used to mean a native addon that compiled C++ at install time, with node-gyp failures, prebuilt-binary gaps, and a forced recompile on every Node major. WebJs dropped better-sqlite3 and uses the runtime's own SQLite instead, `node:sqlite` on Node 24+ and `bun:sqlite` on Bun, so there is no compile step and no binary pinned to your runtime version. Drizzle sits on top and keeps the same code across SQLite and Postgres. The lesson generalizes past this one driver. When the platform grows a capability you were pulling in a fragile dependency for, delete the dependency and use the platform.
4 changes: 2 additions & 2 deletions blog/nextjs-16-file-routing-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ The rule of thumb is the same one that governs `notFound()` and `redirect()`. Th

# not-found is now nearest-wins

This is the fix that quietly matters most for a migrator (issue #848). Previously a thrown `notFound()` in WebJs always rendered the single root `not-found` page. In Next, a `not-found.tsx` is nearest-wins, so a `not-found` deep in your tree takes over for pages beneath it. WebJs now matches that.
This is the fix that quietly matters most for a migrator. Previously a thrown `notFound()` in WebJs always rendered the single root `not-found` page. In Next, a `not-found.tsx` is nearest-wins, so a `not-found` deep in your tree takes over for pages beneath it. WebJs now matches that.

```
app/
Expand Down Expand Up @@ -110,4 +110,4 @@ export function register() {

# The takeaway

WebJs was already file-routing compatible with Next in shape, and these four changes (all shipped in #848 and #849) close the remaining gaps a migrator actually trips on. `forbidden()` and `unauthorized()` are control-flow throws that render the nearest boundary, with honest 403 and 401 status codes and the clear rule that you throw in a render, return an `ActionResult` in an action, and return a `Response` in a route. `not-found` is nearest-wins now, so sections get their own 404s. `params` and `searchParams` read either synchronously or awaited, so your Next code just works. And `instrumentation.js` gives you the boot hook for wiring monitoring. Bring your Next.js routing habits over, and the ones that used to fall through now land.
WebJs was already file-routing compatible with Next in shape, and these four changes close the remaining gaps a migrator actually trips on. `forbidden()` and `unauthorized()` are control-flow throws that render the nearest boundary, with honest 403 and 401 status codes and the clear rule that you throw in a render, return an `ActionResult` in an action, and return a `Response` in a route. `not-found` is nearest-wins now, so sections get their own 404s. `params` and `searchParams` read either synchronously or awaited, so your Next code just works. And `instrumentation.js` gives you the boot hook for wiring monitoring. Bring your Next.js routing habits over, and the ones that used to fall through now land.
Loading
Loading