From 54de0f6c1b5a0ca06f2308b8c285c47849b82950 Mon Sep 17 00:00:00 2001 From: t Date: Wed, 3 Jun 2026 15:58:25 +0530 Subject: [PATCH] docs: add a Next.js to webjs migration guide webjs's positioning is explicitly Next-adjacent (the app/ router, the page/layout/error/loading/not-found/route/middleware conventions, the metadata API, server actions), so Next users are the primary adoption funnel, but there was no doc translating Next idioms into webjs equivalents. A Next dev arrives with the RSC mental model the execution model deliberately does not have, and nothing user-facing corrected it. Add /docs/migrating-from-nextjs: the mental-model shift stated explicitly (no server/client component split, isomorphic modules that hydrate (components) or do not (pages/layouts), the .server file as the one server boundary), a concept-map table (Server/Client Components and use client/use server to isomorphic modules plus .server RPC, hooks to signals, next/link to a plain anchor, next/image as not provided, getServerSideProps to an async page function, generateStaticParams to per-request render plus revalidate, route handlers, middleware, next.config to the webjs.* block), and a before/after sample (a Next Server+Client Component pair and its webjs page-plus-component equivalent). Added to the Getting Started nav and cross-linked from getting-started and architecture. Closes #273 --- docs/app/docs/architecture/page.ts | 2 + docs/app/docs/getting-started/page.ts | 1 + docs/app/docs/layout.ts | 1 + docs/app/docs/migrating-from-nextjs/page.ts | 112 ++++++++++++++++++++ test/docs/migration-page.test.mjs | 61 +++++++++++ 5 files changed, 177 insertions(+) create mode 100644 docs/app/docs/migrating-from-nextjs/page.ts create mode 100644 test/docs/migration-page.test.mjs diff --git a/docs/app/docs/architecture/page.ts b/docs/app/docs/architecture/page.ts index 818ede659..98e754ac3 100644 --- a/docs/app/docs/architecture/page.ts +++ b/docs/app/docs/architecture/page.ts @@ -128,5 +128,7 @@ const resp = await app.handle(new Request('http://x/api/hello')); console.log(await resp.json());

This returns standard Request → Response, usable in Express, Fastify, Bun, Deno, Cloudflare Workers (with the file-system caveat documented in the deployment guide).

+ +

Coming from Next.js? The execution model above (isomorphic modules, no Server/Client Component split, the .server boundary as the one server boundary) is the biggest difference. The Migrating from Next.js guide maps each Next idiom to its webjs equivalent.

`; } diff --git a/docs/app/docs/getting-started/page.ts b/docs/app/docs/getting-started/page.ts index cc904a122..8d9a7bd82 100644 --- a/docs/app/docs/getting-started/page.ts +++ b/docs/app/docs/getting-started/page.ts @@ -134,6 +134,7 @@ Counter.register('my-counter');
  • Components: web components with shadow DOM + scoped styles
  • Server Actions: type-safe server functions callable from client components
  • Backend-Only Mode: use webjs as a pure API framework
  • +
  • Migrating from Next.js: a concept map for Next users (no RSC, isomorphic modules, the .server boundary)
  • `; } diff --git a/docs/app/docs/layout.ts b/docs/app/docs/layout.ts index 0ff9bb701..bad3e01cb 100644 --- a/docs/app/docs/layout.ts +++ b/docs/app/docs/layout.ts @@ -18,6 +18,7 @@ const NAV_SECTIONS = [ { href: '/docs/architecture', label: 'Architecture' }, { href: '/docs/no-build', label: 'No-Build Model' }, { href: '/docs/configuration', label: 'Configuration' }, + { href: '/docs/migrating-from-nextjs', label: 'Migrating from Next.js' }, ], }, { diff --git a/docs/app/docs/migrating-from-nextjs/page.ts b/docs/app/docs/migrating-from-nextjs/page.ts new file mode 100644 index 000000000..ab61a9d8a --- /dev/null +++ b/docs/app/docs/migrating-from-nextjs/page.ts @@ -0,0 +1,112 @@ +import { html } from '@webjsdev/core'; + +export const metadata = { + title: 'Migrating from Next.js | webjs', + description: + 'A concept map from Next.js to webjs: the no-RSC execution model, isomorphic modules and the .server boundary instead of Server/Client Components, plain instead of next/link, an async page function instead of getServerSideProps, and a before/after example.', +}; + +export default function MigratingFromNextjs() { + return html` +

    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:

    + +

    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 { prisma } from '../../../lib/prisma.server.ts';
    +export async function getStats() {
    +  return prisma.stat.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 {
    +  static properties = { start: { type: Number } };
    +  declare 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.

    + `; +} diff --git a/test/docs/migration-page.test.mjs b/test/docs/migration-page.test.mjs new file mode 100644 index 000000000..1744e79e7 --- /dev/null +++ b/test/docs/migration-page.test.mjs @@ -0,0 +1,61 @@ +/** + * Integration test for the /docs/migrating-from-nextjs page (#273). Boots the + * docs app via createRequestHandler (prod) and asserts the page serves, states + * the no-RSC execution model and the .server boundary, carries the concept-map + * table mapping the required Next idioms, includes a before/after sample, is in + * the nav, and is cross-linked from getting-started and architecture. + */ +import { test, before } from 'node:test'; +import assert from 'node:assert/strict'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequestHandler } from '@webjsdev/server'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DOCS_DIR = resolve(__dirname, '..', '..', 'docs'); + +/** @type {(path: string) => Promise} */ +let handle; + +before(async () => { + const app = await createRequestHandler({ appDir: DOCS_DIR, dev: false }); + handle = (path) => app.handle(new Request('http://localhost' + path)); +}); + +test('/docs/migrating-from-nextjs serves and maps the required Next idioms', async () => { + const res = await handle('/docs/migrating-from-nextjs'); + assert.equal(res.status, 200, 'the migration page serves'); + const html = await res.text(); + + // The no-RSC execution model + the .server boundary are stated explicitly. + assert.ok(/no server\/client component split|no RSC|React Server Components/i.test(html), 'states the no-RSC model'); + assert.ok(/\.server/.test(html), 'states the .server boundary'); + + // The concept map covers each required Next idiom. + for (const idiom of [ + 'use client', + 'use server', + 'next/link', + 'next/image', + 'getServerSideProps', + 'generateStaticParams', + 'middleware', + ]) { + assert.ok(html.includes(idiom), `concept map must mention ${idiom}`); + } + + // A before/after code sample (a Next page and its webjs equivalent). + assert.ok(/Next\.js\)/.test(html) && /webjs:/.test(html), 'has a before/after sample'); +}); + +test('the migration page is in the nav and cross-linked from getting-started and architecture', async () => { + for (const from of ['/docs/getting-started', '/docs/architecture']) { + const res = await handle(from); + assert.equal(res.status, 200); + const html = await res.text(); + assert.ok( + /href="\/docs\/migrating-from-nextjs"/.test(html), + `${from} must link the migration guide`, + ); + } +});