Zenn
🔥

Hono & SolidJS で API 付き SPA

2025/03/17に公開

概要

SolidJS に入門した & 以下の記事を読んだので、SolidJS で同じようなことをするためにあれこれ調べながら進めた際のメモ。

https://zenn.dev/laiso/articles/c7eba95ce43feb
https://zenn.dev/yusukebe/articles/06d9cc1714bfb7

元ネタの記事と異なる点は以下:

  • React ではなく SolidJS を使う
  • スタート地点となる Hono のテンプレートは nodejs を使う[1]

現時点でできていないことは以下:

  • Tailwind CSS などの CSS フレームワークの導入

本記事執筆時点のコミットは以下:

https://github.com/mahito1594/solidjs-hono-weatherapp/tree/040277d7d420587bf84fc014853b51df64e65b99

2025-03-20 追記

Vite の server.proxy オプションを利用した方が色々好ましいと感じたので色々と書き換えた。
たとえば以前の設定のまま Panda CSS を入れる方法を思い付けなかった。
相変わらず Node.js で動かす想定だが、Static Asset を利用すれば Cloudflare Workers にデプロイもできると思う。

https://github.com/mahito1594/solidjs-hono-weatherapp/tree/1b81792abc5a318dba2a63066729212149382f2f

プロジェクトセットアップ

create-hono コマンドでセットアップする(テンプレートは nodejs を選ぶ)。
パッケージマネージャーは pnpm を利用している。

pnpm create hono@latest solidjs-hono-weatherapp

Vite の導入

nodejs テンプレートでは tsx を使うようになっているので、tsx の代わりに Vite を使う。
honojs/vite-plugins にある各プラグインの README に従って諸々インストールする。
また、必要ないパッケージはアンインストールしておく。

pnpm uninstall tsx
pnpm add -D vite @hono/vite-build @hono/vite-dev-server @hono/node-server

@hono/vite-dev-server の README に従って vite.config.tssrc/index.ts を編集する。

vite.config.ts
import { defineConfig } from "vite";
import devServer from "@hono/vite-dev-server";
import nodeAdapter from "@hono/vite-dev-server/node";

export default defineConfig({
  plugins: [
    devServer({
      entry: "src/index.ts",
      adapter: nodeAdapter,
    })
  ]
});
src/index.ts
@@ -1,4 +1,3 @@
-import { serve } from '@hono/node-server'
 import { Hono } from 'hono'
 
 const app = new Hono()
@@ -7,9 +6,4 @@ app.get('/', (c) => {
   return c.text('Hello Hono!')
 })
 
-serve({
-  fetch: app.fetch,
-  port: 3000
-}, (info) => {
-  console.log(`Server is running on http://localhost:${info.port}`)
-})
+export default app;

pnpm vite で開発サーバーが起動することを確認できたら OK。
続いて @hono/vite-build のための設定を追加する。

vite.config.ts
 import { defineConfig } from "vite";
+import build from "@hono/vite-build/node";
 import devServer from "@hono/vite-dev-server";
 import nodeAdapter from "@hono/vite-dev-server/node";
 
 export default defineConfig({
   plugins: [
+    build({
+      entry: "src/index.ts",
+    }),
     devServer({
       entry: "src/index.ts",
       adapter: nodeAdapter,

必要に応じて .gitignore を編集しておく。

echo "dist/" >> .gitignore

試しにビルドして localhost:3000 にアクセスしてみる。「Hello Hono!」が表示されれば OK。

pnpm vite build && node dist/index.js

最後に npm script を編集しておく。

package.json
@@ -2,7 +2,9 @@
   "name": "solidjs-hono-weatherapp",
   "type": "module",
   "scripts": {
-    "dev": "tsx watch src/index.ts"
+    "dev": "vite",
+    "build": "vite build",
+    "start": "node dist/index.js"
   },
   "dependencies": {
     "hono": "^4.7.4"

SolidJS のインストール

SolidJS 関連の依存を入れる。

pnpm add -D typescript vite-plugin-solid
pnpm add solid-js

TypeScript

tsconfig.jsonSolidJS のドキュメントcreate-vitesolid-ts テンプレートを参考に書き換える[2]

修正後の tsconfig.json
tsconfig.json
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ]
}

solid-ts テンプレートの設定から types / include / exclude を少し変更している。

tsconfig.node.json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2023"],
    "module": "ESNext",
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "types": ["vite/client"],

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true
  },
  "include": ["vite.config.ts", "src/index.ts"]
}
tsconfig.app.json
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "module": "ESNext",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "preserve",
    "jsxImportSource": "solid-js",
    "types": ["vite/client"],

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true
  },
  "include": ["src"],
  "exclude": ["src/index.ts"]
}

vite.config.ts

SolidJS の vite プラグインにオプション { ssr: true } を渡すのがポイント。
それ以外は @hono/vite-build@hono/vite-dev-server の README に書かれている通りにする。
今回は ./dist/build にビルドしたクライアント JS ファイルを吐き出すようにした。

vite.config.ts
import { defineConfig } from "vite";
import build from "@hono/vite-build/node";
import devServer from "@hono/vite-dev-server";
import nodeAdapter from "@hono/vite-dev-server/node";
import solid from "vite-plugin-solid";

export default defineConfig(({ mode }) => {
  if (mode === "client") {
    return {
      esbuild: {
        jsxImportSource: "solid-js",
      },
      build: {
        rollupOptions: {
          input: "src/client.tsx",
          output: {
            entryFileNames: "build/client.js",
            chunkFileNames: "build/[name]-[hash].js",
            assetFileNames: "build/[name]-[hash].[ext]",
          },
        },
        // emptyOutDir: false,
        copyPublicDir: false,
      },
      plugins: [solid()]
    }
  }

  return {
    plugins: [
      solid({ ssr: true }),
      build({
        entry: "src/index.ts",
      }),
      devServer({
        entry: "src/index.ts",
        adapter: nodeAdapter,
      })
    ]
  };
});

クライアント側のエントリーファイル作成

動きを見るために適当にカウンターのコンポーネントを書く。

src/client.tsx
/* @refresh reload */
import { render } from "solid-js/web";
import App from "./App";

const root = document.getElementById("root")!;
render(() => <App />, root);
src/App.tsx
import { createSignal } from "solid-js";

const App = () => {
  const [count, setCount] = createSignal(0);
  return (
    <>
      <h1>Hono and SolidJS</h1>
      <div>
        <button type="button" onClick={() => setCount(count() + 1)}>
          You clicked me {count()} times  
        </button>
      </div>
    </>
  )
};

export default App;

サーバー側のエントリーファイル

サーバー側のエントリーファイルに SolidJS の renderToString を利用するとエラーが出るため、Hono の Context.html を利用する。

renderToString を利用する場合

src/index.tssrc/index.tsx にリネームし、vite.config.ts 内のファイル名も変えておく。
src/index.tsx の内容は以下の通り:

src/index.tsx
import { Hono } from 'hono'
import { serveStatic } from '@hono/node-server/serve-static';
import { renderToString } from "solid-js/web";

const app = new Hono()

app.use("/build/*", serveStatic({ root: "./dist/" }));

app.get("*", (c) => {
  const html = renderToString(() => (
    <html>
      <head>
        <title>Hono & SolidJS</title>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        {import.meta.env.PROD ? (
          <script type="module" src="/build/client.js"></script>
        ) : ( 
          <script type="module" src="/src/client.tsx"></script>
        )}
      </head>
      <body>
        <div id="root"></div>
      </body>
    </html>
  ));

  return c.html(html);
})

export default app;

Vite の ssr.external オプションに solid-js を追加する:

vite.config.ts
@@ -27,13 +27,16 @@ export default defineConfig(({ mode }) => {
   }
 
   return {
+    ssr: {
+      external: ["solid-js"]
+    },
     plugins: [
       solid({ ssr: true }),

また src/index.tsx で型チェックエラーが生じるため tsconfig.node.jsoncompilerOptions.jsxcompilerOptions.jsxImportSource を設定しておく。

ビルドしたクライアント JS を配信するために serveStatic を利用する[3]

src/index.ts
 import { Hono } from 'hono'
+import { serveStatic } from '@hono/node-server/serve-static';
 
 const app = new Hono()
 
-app.get('/', (c) => {
-  return c.text('Hello Hono!')
+app.use("/build/*", serveStatic({ root: "./dist/" }));
+
+app.get("*", (c) => {
+  const html = `
+    <html>
+      <head>
+        <title>Hono & SolidJS</title>
+        <meta charset="utf-8" />
+        <meta name="viewport" content="width=device-width, initial-scale=1" />
+        ${import.meta.env.PROD ? (
+          '<script type="module" src="/build/client.js"></script>'
+        ) : ( 
+          '<script type="module" src="/src/client.tsx"></script>'
+        )}
+      </head>
+      <body>
+        <div id="root"></div>
+      </body>
+    </html>
+  `;
+
+  return c.html(html);
 })
 
 export default app;

動作確認

ビルド用の npm script を直して pnpm run build && pnpm run start する。
http://localhost:3000 にアクセスしてカウンターが動いたら OK。

package.json
   "type": "module",
   "scripts": {
     "dev": "vite",
-    "build": "vite build",
+    "build": "vite build --mode client && vite build",
     "start": "node dist/index.js"
   },
   "dependencies": {

その他

クライアントルーティング

Solid Router でクライアントルーティングができる。

pnpm add @solidjs/router

動作確認のため、先ほど作った src/App.tsxsrc/routes/Home.tsxsrc/routes/Counter.tsx に分割する。

src/index.ts
 /* @refresh reload */
 import { render } from "solid-js/web";
-import App from "./App";
+import { Route, Router } from "@solidjs/router";
+import { lazy } from "solid-js";
+
+const Home = lazy(() => import("./routes/Home"));
+const Counter = lazy(() => import("./routes/Counter"));
+
+const App = () => (
+  <Router>
+    <Route path="/" component={Home} />
+    <Route path="/counter" component={Counter} />
+  </Router>
+)
 
 const root = document.getElementById("root")!;
 render(() => <App />, root);
routes/Home.tsx
import { A } from "@solidjs/router";

const Home = () => {
  return (
    <>
      <h1>Hono and SolidJS</h1>
      <div>
        <ul>
          <li><A href="/">Home</A></li>
          <li><A href="/counter">Counter</A></li>
        </ul>
      </div>
    </>
  )
}

export default Home;
src/routes/Counter.tsx
import { createSignal } from "solid-js";

const Counter = () => {
  const [count, setCount] = createSignal(0);
  return (
    <>
      <h1>Hono and SolidJS: Counter</h1>
      <div>
        <button type="button" onClick={() => setCount(count() + 1)}>
          You clicked me {count()} times
        </button>
      </div>
    </>
  )
};

export default Counter;

API

Hono で API を作って SolidJS の createResourcecreateAsync でデータ取得するだけ。

例: ClockButton

元ネタにさせてもらった記事にあるコードを SolidJS に移植してみる。
src/index.ts で現在時刻を返す実装は一緒。

src/index.ts
@@ -3,6 +3,10 @@
 
 const app = new Hono()
 
+app.get("/api/clock", (c) => {
+  return c.json({ time: new Date().toLocaleTimeString() });
+})
+
 app.use("/build/*", serveStatic({ root: "./dist/" }));
 
 app.get("*", (c) => {

コンポーネントの実装は SolidJS の Suspense と createResource を利用する。

src/routes/Clock.tsx
import { createResource, Suspense } from "solid-js";

const Clock = () => {
  const [data, { refetch }] = createResource<string>(async () => {
    const response = await fetch("/api/clock");
    const data = await response.json();
    const headers = Array.from(response.headers)
      .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {});
    return JSON.stringify({
      url: response.url,
      status: response.status,
      headers,
      body: data,
    }, null, 2);
  })

  return (
    <>
      <h1>Hono and SolidJS: Clock</h1>
      <div>
        <button type="button" onClick={() => refetch()}>
          Get Server Time
        </button>
        <Suspense fallback={<p>Loading...</p>}>
          <pre>{data()}</pre>
        </Suspense>
      </div>
    </>
  )
};

export default Clock;

src/client.tsxsrc/routes/Home.tsx を修正して動作確認できれば OK。

src/client.tsx
@@ -5,11 +5,13 @@
 
 const Home = lazy(() => import("./routes/Home"));
 const Counter = lazy(() => import("./routes/Counter"));
+const Clock = lazy(() => import("./routes/Clock"));
 
 const App = () => (
   <Router>
     <Route path="/" component={Home} />
     <Route path="/counter" component={Counter} />
+    <Route path="/clock" component={Clock} />
   </Router>
 )
src/routes/Home.tsx
@@ -8,6 +8,7 @@
         <ul>
           <li><A href="/">Home</A></li>
           <li><A href="/counter">Counter</A></li>
+          <li><A href="/clock">Clock</A></li>
         </ul>
       </div>
     </>
脚注
  1. Cloudflare にデプロイするつもりがなかったため ↩︎

  2. 多分もっと良い書き方があるはず ↩︎

  3. これを失念していてしばらく詰まっていた ↩︎

Discussion

ログインするとコメントできます