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. Note the binding differs from Next's: <form action={serverAction}> has no WebJs equivalent and is refused at render (it would leak the action's source into the HTML), so omit action to post to the page's own URL and handle the submission in that page's action export.

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.