Migrating from Next.js
WebJs is deliberately Next-adjacent: the app/ router, the page / layout / error / loading / not-found / route / middleware file conventions, the metadata API, and server actions will all feel familiar. So most of the file layout ports over directly. The one thing you must un-learn is the React Server Components mental model, because WebJs does not have it.
The mental-model shift: there is no RSC
WebJs has no server/client component split. There is no server-component render tree, no Flight protocol, no 'use client' / 'use server' component boundary, and no per-component server-versus-client identity. Stop reasoning about a component as "server" or "client".
Instead, pages, layouts, and components are isomorphic modules (the same source on server and client), and the distinction that matters is how they run:
- Components hydrate. A component's module loads in the browser, the custom element upgrades, and its
render(), lifecycle,@eventhandlers, and signals run on the client. This is islands-style, per element, and it is where ALL interactivity lives. A Next "Client Component" maps here, but you do not write a directive: writing a@clickor a signal read is what requests the JavaScript for that behavior. - Pages and layouts do NOT hydrate. Their function runs only on the server to produce HTML and is never re-invoked in the browser. So a page or layout cannot be interactive in its own markup. An
@clickin a page template is dropped at SSR. To make something interactive, put it in a component and render that component's tag. A Next "Server Component" page maps here.
The single server boundary is the .server.{js,ts} FILE, and it is an RPC plus source-protection mechanism, NOT an RSC server component. A file with 'use server' exposes its exports as typed RPC stubs that the browser calls; a file without it is a server-only utility whose source never reaches the browser. So the way to keep your database client or a secret off the client is the .server file boundary, not a component annotation. See Architecture for the full execution model and Server Actions for the RPC model.
Concept map
| Next.js | webjs |
|---|---|
| Server Component | An isomorphic page / layout / component (no split). Server-only data comes from a .server action, not a server component. |
Client Component / 'use client' | A WebComponent. Interactivity lives in components, which hydrate. No directive: a @click or signal read requests the JavaScript. |
'use server' action (in a component file) | A .server.{js,ts} file with 'use server'. It is a FILE boundary, not an in-component directive. Import it and call it; the browser import is rewritten to a typed RPC stub. |
React hooks (useState, useEffect) | Signals (signal / computed from @webjsdev/core) plus the lit-style lifecycle hooks (connectedCallback, updated, ...). State lives in components. |
next/link | A plain <a href>. The client router auto-enhances same-origin links into partial-swap navigations. Prefetch is on by default; tune it with data-prefetch. |
next/image | Not provided. Use a plain <img> (with width / height / loading="lazy") and layer an image service if you need one. WebJs ships no image optimizer. |
getServerSideProps / getStaticProps | An async page function: export default async function Page({ params, searchParams, url }). It runs on the server; fetch your data there (through a .server action) and return the markup. |
generateStaticParams / static export | Not needed. Pages render per request. Opt a same-for-everyone page into the HTML cache with export const revalidate = N, the no-build equivalent of ISR. |
generateMetadata / metadata | The same exports, near-Next parity. Type them with the exported Metadata / MetadataContext types. JSON-LD via metadata.jsonLd. |
Route Handler (route.ts) | route.{js,ts} exporting named GET / POST / ... functions. Nearly identical. Add a WS export for a WebSocket endpoint. |
middleware.ts | middleware.{js,ts}, default-exporting async (req, next) => Response. Per-segment middleware is supported too. |
layout.tsx / loading.tsx / error.tsx / not-found.tsx | The same file names (.{js,ts}). loading auto-wraps the sibling page in a Suspense boundary. |
next.config.js | A "webjs" block in package.json (headers, redirects, trailingSlash, basePath, csp, the body / timeout knobs). Typed by WebjsConfig. |
unstable_cache / 'use cache' | cache(fn, { key, ttl, tags }) from @webjsdev/server. Invalidate with revalidateTag / revalidatePath (same names as Next). |
| Suspense / streaming | Suspense from @webjsdev/core, plus the auto loading.{js,ts} boundary. |
| Font / image optimization, i18n | Not provided. Layer libraries on top. WebJs stays small and standards-based. |
Before and after
A Next.js App Router page that fetches on the server and renders an interactive counter, split across a Server Component and a Client Component:
// app/dashboard/page.tsx (Next.js)
import { getStats } from '@/lib/stats';
import { Counter } from './counter';
export default async function Dashboard() {
const stats = await getStats(); // runs on the server
return (
<main>
<h1>{stats.title}</h1>
<Counter start={stats.count} /> // a Client Component
</main>
);
}
// app/dashboard/counter.tsx (Next.js)
'use client';
import { useState } from 'react';
export function Counter({ start }: { start: number }) {
const [n, setN] = useState(start);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}
The WebJs equivalent. The page is an async server function that reads data through a .server query, and the interactive part is a web component that hydrates:
// modules/stats/queries/get-stats.server.ts (webjs: the server boundary)
'use server';
import { db } from '#db/connection.server.ts';
export async function getStats() {
return db.query.stats.findFirst();
}
// app/dashboard/page.ts (webjs: an async page function, no hydration)
import { html } from '@webjsdev/core';
import type { PageProps } from '@webjsdev/core';
import { getStats } from '#modules/stats/queries/get-stats.server.ts';
import '#components/counter.ts'; // register the element
export default async function Dashboard(_props: PageProps) {
const stats = await getStats(); // runs on the server
return html`
<main>
<h1>${stats.title}</h1>
<my-counter start=${stats.count}></my-counter>
</main>
`;
}
// components/counter.ts (webjs: a component, this is where JS ships)
import { WebComponent, html } from '@webjsdev/core';
export class Counter extends WebComponent({ start: Number }) {
constructor() { super(); this.start = 0; }
render() {
return html`<button @click=${() => { this.start = this.start + 1; }}>${this.start}</button>`;
}
}
Counter.register('my-counter');
The shape is the same (a server-rendered shell with an interactive island), but there is no 'use client' directive and no Server/Client Component pair. The page renders on the server and never hydrates; the <my-counter> element hydrates and owns its interactivity; the data crosses the .server boundary as an RPC-backed query.
What ports cleanly, and what does not
Ports directly: the app/ directory layout, dynamic segments ([id], [...rest], [[...rest]]), route groups ((group)), the metadata API, route handlers, middleware, and the loading / error / not-found conventions.
Needs rethinking: anything written as a Client Component becomes a web component; anything fetching server data moves into a .server action; React state becomes signals; next/link becomes a plain link. Write progressive-enhancement-first: a <form> plus a server action instead of a fetch in a click handler, since the form works without JavaScript and the client router upgrades it automatically.
Not provided: image and font optimization, i18n, and a static export. WebJs is a no-build, standards-based framework, so these are libraries you layer on, not built-ins.
Next steps: read Getting Started to scaffold an app, Architecture for the execution model in depth, and Progressive Enhancement for the design posture that replaces the Client Component habit.