Skip to content

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, @event handlers, 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 @click or 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 @click in 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.jswebjs
Server ComponentAn 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/linkA 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/imageNot 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 / getStaticPropsAn 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 exportNot 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 / metadataThe 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.tsmiddleware.{js,ts}, default-exporting async (req, next) => Response. Per-segment middleware is supported too.
layout.tsx / loading.tsx / error.tsx / not-found.tsxThe same file names (.{js,ts}). loading auto-wraps the sibling page in a Suspense boundary.
next.config.jsA "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 / streamingSuspense from @webjsdev/core, plus the auto loading.{js,ts} boundary.
Font / image optimization, i18nNot 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.