Skip to content

Next Helper

WARNING

Experimental. @devframes/next's API is still settling — expect changes before a stable release.

@devframes/next hosts devframes from a Next.js App Router app. Next runs on webpack/Turbopack rather than Vite, so it hosts through a route handler instead of the Vite Bridge: the package serves each devframe's SPA and its __connection.json from a single fetch handler your catch-all route delegates to, reusing devframe's own serveStaticHandler for SPA fallback, content types, and path-traversal guarding.

It comes in three parts:

  1. withDevframe() — applies the one Next config setting a devframe host needs.
  2. createDevframeNextHandler() — hosts a single devframe (the common case).
  3. createDevframeNextHost() — the lower-level primitive for a hub mounting many devframes at once.

Plus a React client surface at @devframes/next/client.

Config

ts
import { withDevframe } from '@devframes/next'

export default withDevframe({
  // ...your own Next config
})

withDevframe sets skipTrailingSlashRedirect: true and preserves the rest. Mounted SPAs are served at /__<id>/ and reference their assets relatively (./_next/…); Next's default trailing-slash redirect (/__git//__git) would re-root those paths and 404 every asset, so a host serves the base verbatim.

Hosting a single devframe

createDevframeNextHandler(definition) statically serves the devframe's built SPA and starts a side-car RPC/WebSocket server, advertising it at <base>/__connection.json. Delegate your catch-all route to its fetch:

ts
import { createDevframeNextHandler } from '@devframes/next'
import myDevframe from '@/devframe'

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

const handler = createDevframeNextHandler(myDevframe)
export const GET = handler.fetch

The base defaults to def.basePath ?? '/__<id>/'. close() shuts the side-car down; ready resolves once it's listening.

OptionDefaultDescription
basedef.basePath ?? '/__<id>/'Mount path for the SPA.
hostdef.cli?.host ?? 'localhost'Side-car bind host.
portresolved from def.cli?.portSide-car port.
flagsForwarded to def.setup(ctx, { flags }).
authfalsetrue for devframe's OTP gate, or a handler. The Next app owns auth by default.

Hosting a hub

For many devframes at once, use createDevframeNextHost() with @devframes/hub. Its host accumulates every mountStatic / mountConnectionMeta call into one fetch handler:

ts
import { createHubContext, mountDevframe } from '@devframes/hub/node'
import { createDevframeNextHost } from '@devframes/next'
import { startHttpAndWs } from 'devframe/node'

const nextHost = createDevframeNextHost({
  resolveOrigin: () => 'http://localhost:3000',
  getStorageDir: scope => resolveStorageDir(scope),
})

const context = await createHubContext({ host: nextHost.host, mode: 'dev' })
await mountDevframe(context, myDevframe)
nextHost.host.mountConnectionMeta('/__hub') // the hub's own connection base

const started = await startHttpAndWs({ context, port, auth: false })
nextHost.setConnectionMeta({ backend: 'websocket', websocket: started.port })

export const hub = { fetch: nextHost.fetch }
ts
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

export async function GET(request: Request): Promise<Response> {
  return hub.fetch(request) // serves every mounted SPA + connection meta
}

createDevframeNextHost returns { host, fetch, setConnectionMeta }:

  • host — the DevframeHost to pass to createHubContext / createHostContext.
  • fetch(request) — the handler your route delegates to. Connection meta is matched before the static handler, so an SPA fallback never swallows a <base>/__connection.json discovery fetch; a miss returns a bare 404.
  • setConnectionMeta(meta) — publish the live meta once the RPC/WS port is known. Until then, meta requests answer 503 so a racing client retries rather than caching a wrong endpoint.

React client

@devframes/next/client connects to the RPC backend and provides the client to your component tree — the React counterpart to @devframes/nuxt's $rpc plugin. Children render immediately, so your shell and a connection indicator stay visible while the client connects.

tsx
'use client'
import { RpcProvider } from '@devframes/next/client'

export function Providers({ children }: { children: React.ReactNode }) {
  return <RpcProvider baseURL="/__my-tool/">{children}</RpcProvider>
}

useRpc() returns the connected DevframeRpcClient, or null while connecting; scope it to your tool's namespace. useRpcStatus() returns the live { status, error } for a connection indicator.

tsx
'use client'
import { useRpc, useRpcStatus } from '@devframes/next/client'

export function Panel() {
  const rpc = useRpc()?.scope('my-tool:')
  const { status, error } = useRpcStatus()
  if (!rpc)
    return <p>{error ? `connection failed — ${error.message}` : 'connecting…'}</p>
  // rpc.rpc.call('get-payload'), rpc.sharedState, …
}

Both hooks throw outside a <RpcProvider>. Theming and layout stay app-owned.

Runtime

Route handlers that call fetch pin export const runtime = 'nodejs': the static handler streams built SPA files from disk, and the side-car RPC/WS server is a Node process.

See also

Released under the MIT License.