はじめに vinxi はフルスタックアプリケーションやメタフレームワークの構築が可能なパッケージです。 開発サーバーとバンドラーに Vite を、本番サーバーには Nitro を使用しています。 SolidStart や TanstackStart で採用されています。 今回は vinxi を使ってメタフレームワークを作ってみます。 進めるにあたり、公式のサンプルや以下の記事を参考にさせていただきました。 Bullding a React Metaframework with Vinxi Simple RSC With Vinxi セットアップ package.json を作成し、 npm init -y 以下の内容を追加します。 { " name ": " try-vinxi ", " version ": " 1.0.0 ", " description ": "", " type ": " module ", " scripts ": { " dev ": " vinxi dev ", " build ": " vinxi build ", " preview ": " vinxi preview " } , " keywords ": [] , " author ": "", " license ": " ISC ", " dependencies ": { " @vinxi/react ": " ^0.2.5 ", " @vitejs/plugin-react ": " ^4.3.4 ", " react ": " ^18.3.1 ", " react-dom ": " ^18.3.1 ", " vinxi ": " ^0.5.0 " } , " devDependencies ": { " @types/react ": " ^18.3.12 ", " @types/react-dom ": " ^18.3.1 ", " typescript ": " ^5.7.2 " } } tsconfig.json を作成します。 { " compilerOptions ": { " module ": " ESNext ", " moduleResolution ": " bundler ", " jsx ": " react-jsx ", " esModuleInterop ": true , " strict ": true } } 依存関係をインストールします。 npm install SPA まずはシンプルな SPA モードを作成します。 index.ts を作成 ルートに index.ts を作成します。空の HTML を返すだけのハンドラーです。 import { eventHandler } from 'vinxi/http' ; export default eventHandler(() => { return new Response ( `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Vinxi</title> </head> <body> <div id="root"></div> <script src="./src/entry-client.tsx" type="module"></script> </body> </html>` , { status : 200 , headers : { 'Content-Type' : 'text/html' , } , } ); } ); entry-client.tsx を作成 src ディレクトリを作成し、その中に entry-client.tsx を作成します。 import { createRoot } from 'react-dom/client' ; import Counter from './counter' ; createRoot( document . getElementById ( 'root' )!).render( < Counter /> ); counter.tsx を作成します。 import { useState } from 'react' ; export default function Counter () { const [ count , setCount ] = useState( 0 ); return ( < div > < h1 > Counter </ h1 > < p > { count } </ p > < button onClick = { () => setCount(count + 1 ) } > Increment </ button > </ div > ); } app.config.ts を作成 ルートに app.config.ts を作成し、 createApp 内に vinxi の設定を記述します。 @vitejs/plugin-react を追加します。vinxi は Vite を使用しているため、Vite のプラグインをそのまま使用できます。 npm install @vitejs/plugin-react spa ルーターを追加します。handler には先ほど作成した index.ts を指定します。 import { createApp } from 'vinxi' ; import pluginReact from '@vitejs/plugin-react' ; export default createApp( { routers : [ { name : 'spa' , type : 'spa' , handler : './index.ts' , target : 'browser' , plugins : () => [ pluginReact() ] , } , ] , } ); 開発サーバーを起動します。 npm run dev カウンターが表示されることを確認します 🎉 SSR 次に SSR モードを作成します。 app.config.ts を編集 まずは app.config.ts を編集します。 spa ルーターを削除し、 client と ssr ルーターを追加します。 import { createApp } from 'vinxi' ; import pluginReact from '@vitejs/plugin-react' ; export default createApp( { routers : [ { name : 'client' , type : 'client' , handler : './src/entry-client.tsx' , target : 'browser' , plugins : () => [ pluginReact() ] , base : '/_build' , } , { name : 'ssr' , type : 'http' , handler : './src/entry-server.tsx' , target : 'server' , plugins : () => [ pluginReact() ] , } , ] , } ); 各ファイルを作成していきます。 app.tsx を作成 src ディレクトリに app.tsx を作成します。 createAssets は各アセットを注入するコンポーネントを作成します。遅延コンポーネントになっているため、 Suspense でラップします。 import { getManifest } from 'vinxi/manifest' ; import { createAssets } from '@vinxi/react' ; import { Suspense } from 'react' ; import Counter from './counter' ; const Assets = createAssets( getManifest( 'client' ).handler, getManifest( 'client' ) ); export default function App () { return ( < html > < head > < Suspense > < Assets /> </ Suspense > </ head > < body > < div id = "root" > < Counter /> </ div > </ body > </ html > ); } entry-client.tsx を編集 createRoot を hydrateRoot に変更します。 クライアントのランタイムを読み込むために vinxi/client をインポートします。 import { hydrateRoot } from 'react-dom/client' ; import App from './app' ; import 'vinxi/client' ; hydrateRoot( document , < App /> ); entry-server.tsx を作成 src ディレクトリに entry-server.tsx を作成します。 import { getManifest } from 'vinxi/manifest' ; import { eventHandler } from 'vinxi/http' ; import { renderToPipeableStream } from 'react-dom/server' ; import App from './app' ; export default eventHandler( { handler : async ( event ) => { const clientManifest = getManifest( 'client' ); const stream = await new Promise ( async ( resolve ) => { const stream = renderToPipeableStream( < App /> , { onShellReady () { resolve(stream); } , bootstrapModules : [ clientManifest.inputs[clientManifest.handler ] .output.path, ], bootstrapScriptContent : `window.manifest = ${ JSON . stringify ( await clientManifest.json() ) } ` , } ); } ); event.node.res.setHeader( 'Content-Type' , 'text/html' ); return stream; } , } ); 開発サーバーを起動します。 npm run dev SSR された HTML が返ってきました 🎉 File system routing vinxi はファイルシステムルーティングを作成するための機能を提供しています。 FileSystemRouter を作成 vinxi の BaseFileSystemRouter を継承した FileSystemRouter を作成します。今回は app.config.ts の中に書いていきます。 toPath メソッドは引数にファイルのパスを受け取り、ルートのパスを返します。 cleanPath はディレクトリ名と拡張子を取り除く vinxi のユーティリティ関数です。 toRoute メソッドは引数にファイルのパスを受け取り、ルートオブジェクトを返します。このルートオブジェクトは vinxi/routes モジュールによってアプリケーションに提供されます。 class FileSystemRouter extends BaseFileSystemRouter { toPath ( src : string ) { const routePath = cleanPath(src, this .config) . slice ( 1 ) . replace ( /index$/ , '' ); return routePath?. length > 0 ? `/ ${ routePath } ` : '/' ; } toRoute ( filePath : string ) { return { path : this .toPath(filePath), $component : { src : filePath, pick : [ 'default' ] , } , } ; } } 各ルーターの routes プロパティに FileSystemRouter を追加します。最終的に app.config.ts は以下のようになります。 import { createApp } from 'vinxi' ; import pluginReact from '@vitejs/plugin-react' ; import { BaseFileSystemRouter, cleanPath } from 'vinxi/fs-router' ; import path from 'node:path' ; class FileSystemRouter extends BaseFileSystemRouter { toPath ( src : string ) { const routePath = cleanPath(src, this .config) . slice ( 1 ) . replace ( /index$/ , '' ); return routePath?. length > 0 ? `/ ${ routePath } ` : '/' ; } toRoute ( filePath : string ) { return { path : this .toPath(filePath), $component : { src : filePath, pick : [ 'default' ] , } , } ; } } export default createApp( { routers : [ { name : 'client' , type : 'client' , handler : './src/entry-client.tsx' , target : 'browser' , plugins : () => [ pluginReact() ] , base : '/_build' , routes : ( router , app ) => { return new FileSystemRouter( { dir : path. join (__dirname, 'src/routes' ), extensions : [ 'tsx' , 'ts' ] , } , router, app ); } , } , { name : 'ssr' , type : 'http' , handler : './src/entry-server.tsx' , target : 'server' , plugins : () => [ pluginReact() ] , routes : ( router , app ) => { return new FileSystemRouter( { dir : path. join (__dirname, 'src/routes' ), extensions : [ 'tsx' , 'ts' ] , } , router, app ); } , } , ] , } ); 各ルートファイルを作成 先ほど指定した src/routes ディレクトリに、いくつかルートファイルを作成します。 src/routes/index.tsx import Counter from '../counter' ; export default function Index () { return ( < div > < h1 > Index </ h1 > < Counter /> </ div > ); } src/routes/about.tsx export default function About () { return ( < div > < h1 > About </ h1 > < p > This is the about page. </ p > </ div > ); } src/routes/docs/guide.tsx export default function Guide () { return ( < div > < h1 > Guide </ h1 > < p > This is the guide page. </ p > </ div > ); } React Router をインストール 今回はルーターライブラリに React Router v6 を使用します。 npm install react-router-dom@6 app.tsx を編集 import { getManifest } from 'vinxi/manifest' ; import { createAssets, lazyRoute } from '@vinxi/react' ; import { Suspense } from 'react' ; import fileRoutes from 'vinxi/routes' ; import { Route, Routes } from 'react-router-dom' ; const clientManifest = getManifest( 'client' ); const ssrManifest = getManifest( 'ssr' ); const routes = fileRoutes. map (( route ) => ( { ...route, component : lazyRoute(route.$component, clientManifest, ssrManifest), } )); const Assets = createAssets(clientManifest.handler, clientManifest); export default function App () { return ( < html > < head > < Suspense > < Assets /> </ Suspense > </ head > < body > < div id = "root" > < Suspense > < Routes > { routes. map (( route ) => ( < Route key = { route.path } path = { route.path } element = { < route . component /> } /> )) } </ Routes > </ Suspense > </ div > </ body > </ html > ); } entry-server.tsx を編集 App コンポーネントを StaticRouter でラップし、 event.path を location に渡します。 <StaticRouter location = { event . path } > < App /> </StaticRouter> entry-client.tsx を編集 App コンポーネントを BrowserRouter でラップします。 hydrateRoot( document , < BrowserRouter > < App /> </ BrowserRouter > ); index.tsx にリンクを追加 export default function Index () { return ( < div > < h1 > Index </ h1 > < Counter /> < Link to = "/about" > About </ Link > < Link to = "/docs/guide" > Docs </ Link > </ div > ); } 開発サーバーを起動します。 npm run dev リンクから各ページに遷移できることを確認します 🎉 おわりに 今回は vinxi を使って SSR とファイルシステムルーティングを備えたメタフレームワークを作成しました。 他にもミドルウェアや Server functions など、vinxi には様々な機能があります。 自分だけのメタフレームワークを作ってみるのも楽しそうですね。