Vinxi完全ガイド - SolidStart/TanStack Startを支えるメタフレームワーク基盤の全貌
Vinxi完全ガイド - SolidStart/TanStack Startを支えるメタフレームワーク基盤の全貌
Vinxiとは
Vinxiは次世代のメタフレームワーク基盤として、Next.js、Remix、SvelteKitのような統合フレームワークを構築するための土台となるツールです。
従来のメタフレームワークの課題
【従来の問題】
Next.js → Webpack専用設計、カスタマイズ困難
Remix → Vite移行に時間とコスト
SvelteKit → Svelte専用、他UIライブラリに流用不可
各フレームワークが独自にビルドシステムを再実装
→ 重複する機能(SSR、ルーティング、API)を個別開発
→ 新しいUIライブラリごとに全体を作り直し
Vinxiの解決策
【Vinxiのアプローチ】
Vite + メタフレームワーク共通機能を分離
→ 任意のUIライブラリ(React/Solid/Vue)で再利用可能
→ SSR、ルーティング、API統合をプラグイン化
結果:
✅ SolidStart → Vinxi基盤で構築
✅ TanStack Start → Vinxi基盤で構築
✅ 新しいフレームワーク → Vinxi上で迅速に開発可能
主要な特徴
- Viteベース - 高速HMR、最新ツールチェーン
- フレームワーク非依存 - React、Solid、Vue、任意のUIライブラリ対応
- Full-Stack型安全 - サーバー/クライアント間の完全な型共有
- モジュラー設計 - 必要な機能だけ選択可能
- 多様なレンダリング - SSR、SSG、SPA、RSC対応
インストールとセットアップ
新規プロジェクト作成
# npm
npm create vinxi@latest
# 対話形式でプロジェクト設定
? Project name: my-app
? Select framework: React / Solid / Vue
? Enable TypeScript: Yes
? Enable SSR: Yes
cd my-app
npm install
npm run dev
手動セットアップ
npm install vinxi vite
// app.config.ts
import { createApp } from 'vinxi';
export default createApp({
routers: [
{
name: 'public',
type: 'static',
dir: './public'
},
{
name: 'client',
type: 'client',
handler: './app/client.tsx',
target: 'browser',
base: '/_build'
},
{
name: 'server',
type: 'http',
handler: './app/server.ts',
target: 'server'
}
]
});
基本構造
Routerの概念
Vinxiは複数のRouterを組み合わせてアプリを構築します。
// app.config.ts
import { createApp } from 'vinxi';
export default createApp({
routers: [
// 1. 静的ファイル配信
{
name: 'public',
type: 'static',
dir: './public',
base: '/'
},
// 2. クライアントアプリ
{
name: 'client',
type: 'spa', // または 'client' (SSR)
handler: './app/client.tsx',
target: 'browser',
base: '/_build'
},
// 3. APIサーバー
{
name: 'api',
type: 'http',
handler: './app/api.ts',
target: 'server',
base: '/api'
},
// 4. SSRサーバー
{
name: 'ssr',
type: 'http',
handler: './app/ssr.ts',
target: 'server',
base: '/'
}
]
});
Reactアプリケーション例
// app/client.tsx
import { StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { App } from './App';
hydrateRoot(
document.getElementById('root')!,
<StrictMode>
<App />
</StrictMode>
);
// app/App.tsx
import { useState } from 'react';
export const App = () => {
const [count, setCount] = useState(0);
return (
<div>
<h1>Count: {count}</h1>
<button onClick={() => setCount(c => c + 1)}>
Increment
</button>
</div>
);
};
// app/server.ts
import { eventHandler } from 'vinxi/http';
import { renderToString } from 'react-dom/server';
import { App } from './App';
export default eventHandler(async (event) => {
const html = renderToString(<App />);
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Vinxi App</title>
</head>
<body>
<div id="root">${html}</div>
<script type="module" src="/_build/client.js"></script>
</body>
</html>
`;
});
ファイルベースルーティング
TanStack Routerとの統合
npm install @tanstack/react-router vinxi-router
// app.config.ts
import { createApp } from 'vinxi';
import { tanstackRouter } from 'vinxi-router/tanstack';
export default createApp({
routers: [
{
name: 'client',
type: 'spa',
handler: './app/client.tsx',
target: 'browser',
plugins: () => [
tanstackRouter({
routesDirectory: './app/routes'
})
]
}
]
});
// app/routes/index.tsx
export default function Home() {
return <h1>Home Page</h1>;
}
// app/routes/about.tsx
export default function About() {
return <h1>About Page</h1>;
}
// app/routes/blog/$postId.tsx
import { useParams } from '@tanstack/react-router';
export default function BlogPost() {
const { postId } = useParams({ from: '/blog/$postId' });
return <h1>Blog Post: {postId}</h1>;
}
ネストされたルート
app/routes/
├── _layout.tsx # 共通レイアウト
├── index.tsx # /
├── blog/
│ ├── _layout.tsx # /blog のレイアウト
│ ├── index.tsx # /blog
│ └── $postId.tsx # /blog/:postId
└── admin/
├── _layout.tsx # /admin のレイアウト
├── index.tsx # /admin
└── users.tsx # /admin/users
// app/routes/_layout.tsx
import { Outlet } from '@tanstack/react-router';
export default function Layout() {
return (
<div>
<header>
<nav>
<a href="/">Home</a>
<a href="/blog">Blog</a>
<a href="/admin">Admin</a>
</nav>
</header>
<main>
<Outlet /> {/* 子ルートがここにレンダリング */}
</main>
</div>
);
}
Server Functions(RPC)
クライアントからサーバー関数を直接呼び出し
// app/api/users.ts
'use server';
import { db } from './db';
export async function getUsers() {
return db.query('SELECT * FROM users');
}
export async function createUser(name: string, email: string) {
const result = await db.query(
'INSERT INTO users (name, email) VALUES (?, ?)',
[name, email]
);
return { id: result.insertId, name, email };
}
export async function deleteUser(id: number) {
await db.query('DELETE FROM users WHERE id = ?', [id]);
return { success: true };
}
// app/routes/users.tsx
'use client';
import { useState, useEffect } from 'react';
import { getUsers, createUser, deleteUser } from '../api/users';
export default function Users() {
const [users, setUsers] = useState([]);
const [name, setName] = useState('');
const [email, setEmail] = useState('');
useEffect(() => {
loadUsers();
}, []);
async function loadUsers() {
const data = await getUsers(); // サーバー関数を直接呼び出し
setUsers(data);
}
async function handleSubmit(e) {
e.preventDefault();
await createUser(name, email); // 型安全なRPC呼び出し
setName('');
setEmail('');
loadUsers();
}
async function handleDelete(id) {
await deleteUser(id);
loadUsers();
}
return (
<div>
<h1>Users</h1>
<form onSubmit={handleSubmit}>
<input value={name} onChange={e => setName(e.target.value)} />
<input value={email} onChange={e => setEmail(e.target.value)} />
<button type="submit">Add User</button>
</form>
<ul>
{users.map(user => (
<li key={user.id}>
{user.name} ({user.email})
<button onClick={() => handleDelete(user.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
型安全性の実現
// 自動生成される型定義(内部的に)
type GetUsers = () => Promise<User[]>;
type CreateUser = (name: string, email: string) => Promise<User>;
type DeleteUser = (id: number) => Promise<{ success: boolean }>;
// クライアントコードでは完全な型補完が効く
const users = await getUsers(); // User[]型
const newUser = await createUser('Alice', 'alice@example.com'); // User型
データフェッチング
Loaderパターン
// app/routes/blog/$postId.tsx
import { useParams, useLoaderData } from '@tanstack/react-router';
// Loader(サーバー側で実行)
export async function loader({ params }) {
const post = await fetch(`https://api.example.com/posts/${params.postId}`)
.then(r => r.json());
return { post };
}
// コンポーネント
export default function BlogPost() {
const { post } = useLoaderData({ from: '/blog/$postId' });
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
createAsyncパターン(SolidStart風)
// app/routes/users.tsx
import { createAsync } from 'vinxi/data';
import { getUsers } from '../api/users';
export default function Users() {
const users = createAsync(() => getUsers());
return (
<div>
<h1>Users</h1>
<Suspense fallback={<p>Loading...</p>}>
<ul>
{users()?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</Suspense>
</div>
);
}
SSR(Server-Side Rendering)
Reactでの実装
// app/ssr.ts
import { eventHandler } from 'vinxi/http';
import { renderToPipeableStream } from 'react-dom/server';
import { App } from './App';
export default eventHandler(async (event) => {
return new Promise((resolve, reject) => {
const { pipe } = renderToPipeableStream(<App />, {
onShellReady() {
const stream = pipe(event.node.res);
resolve(stream);
},
onError(error) {
reject(error);
}
});
});
});
Streamingレスポンス
// app/App.tsx
import { Suspense } from 'react';
import { SlowComponent } from './SlowComponent';
export const App = () => (
<html>
<body>
<h1>Fast Content</h1>
<Suspense fallback={<p>Loading slow content...</p>}>
<SlowComponent />
</Suspense>
</body>
</html>
);
// app/SlowComponent.tsx
export async function SlowComponent() {
const data = await fetchSlowData(); // 遅いデータ取得
return <div>{data}</div>;
}
async function fetchSlowData() {
await new Promise(r => setTimeout(r, 2000));
return 'Slow data loaded!';
}
API Routes
RESTful API
// app/api/posts.ts
import { eventHandler, getQuery, readBody } from 'vinxi/http';
import { db } from './db';
// GET /api/posts
export const GET = eventHandler(async (event) => {
const { page = 1, limit = 10 } = getQuery(event);
const posts = await db.query(
'SELECT * FROM posts LIMIT ? OFFSET ?',
[limit, (page - 1) * limit]
);
return { posts, page, limit };
});
// POST /api/posts
export const POST = eventHandler(async (event) => {
const { title, content } = await readBody(event);
if (!title || !content) {
throw createError({
statusCode: 400,
message: 'Title and content are required'
});
}
const result = await db.query(
'INSERT INTO posts (title, content) VALUES (?, ?)',
[title, content]
);
return { id: result.insertId, title, content };
});
// DELETE /api/posts/:id
export const DELETE = eventHandler(async (event) => {
const id = event.context.params.id;
await db.query('DELETE FROM posts WHERE id = ?', [id]);
return { success: true };
});
tRPC統合
npm install @trpc/server @trpc/client @trpc/react-query
// app/api/trpc/[trpc].ts
import { initTRPC } from '@trpc/server';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { z } from 'zod';
const t = initTRPC.create();
const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const user = await db.query('SELECT * FROM users WHERE id = ?', [input.id]);
return user;
}),
createUser: t.procedure
.input(z.object({
name: z.string(),
email: z.string().email()
}))
.mutation(async ({ input }) => {
const result = await db.query(
'INSERT INTO users (name, email) VALUES (?, ?)',
[input.name, input.email]
);
return { id: result.insertId, ...input };
})
});
export type AppRouter = typeof appRouter;
export default eventHandler((event) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req: event.node.req,
router: appRouter,
createContext: () => ({})
})
);
// app/client.tsx
import { createTRPCReact } from '@trpc/react-query';
import { httpBatchLink } from '@trpc/client';
import type { AppRouter } from './api/trpc/[trpc]';
export const trpc = createTRPCReact<AppRouter>();
const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc'
})
]
});
// 使用例
function UserProfile({ userId }) {
const { data, isLoading } = trpc.getUser.useQuery({ id: userId });
if (isLoading) return <p>Loading...</p>;
return <div>{data.name}</div>;
}
環境変数管理
設定ファイル
// app.config.ts
import { createApp } from 'vinxi';
export default createApp({
server: {
env: {
DATABASE_URL: process.env.DATABASE_URL,
API_KEY: process.env.API_KEY
}
},
routers: [/* ... */]
});
型安全な環境変数
// app/env.ts
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(1),
NODE_ENV: z.enum(['development', 'production', 'test'])
});
export const env = envSchema.parse(process.env);
// 使用例
import { env } from './env';
const db = createConnection(env.DATABASE_URL);
ミドルウェア
認証ミドルウェア
// app/middleware/auth.ts
import { eventHandler, getCookie } from 'vinxi/http';
export const authMiddleware = eventHandler(async (event) => {
const token = getCookie(event, 'auth_token');
if (!token) {
throw createError({
statusCode: 401,
message: 'Unauthorized'
});
}
const user = await verifyToken(token);
event.context.user = user;
});
// app/api/protected.ts
import { eventHandler } from 'vinxi/http';
import { authMiddleware } from '../middleware/auth';
export default eventHandler(async (event) => {
await authMiddleware(event); // 認証チェック
const user = event.context.user;
return { message: `Hello, ${user.name}!` };
});
ロギングミドルウェア
// app/middleware/logger.ts
import { eventHandler } from 'vinxi/http';
export const loggerMiddleware = eventHandler(async (event) => {
const start = Date.now();
console.log(`[${new Date().toISOString()}] ${event.method} ${event.path}`);
event.node.res.on('finish', () => {
const duration = Date.now() - start;
console.log(`[${new Date().toISOString()}] ${event.method} ${event.path} - ${event.node.res.statusCode} (${duration}ms)`);
});
});
デプロイ
Vercel
npm install -g vercel
vercel
// vercel.json
{
"buildCommand": "npm run build",
"outputDirectory": ".output/public",
"functions": {
"app/server.ts": {
"runtime": "@vercel/node@3"
}
}
}
Netlify
npm install -g netlify-cli
netlify deploy --prod
# netlify.toml
[build]
command = "npm run build"
publish = ".output/public"
[[redirects]]
from = "/api/*"
to = "/.netlify/functions/server/:splat"
status = 200
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
Cloudflare Pages
npm run build
npx wrangler pages deploy .output/public
# wrangler.toml
name = "vinxi-app"
compatibility_date = "2024-01-01"
[site]
bucket = ".output/public"
Docker
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
docker build -t vinxi-app .
docker run -p 3000:3000 vinxi-app
SolidStart統合
プロジェクト作成
npx create-solid@latest
? Project name: my-solid-app
? Use TypeScript: Yes
? Use SolidStart (SSR): Yes
cd my-solid-app
npm install
ファイル構造
my-solid-app/
├── app.config.ts # Vinxi設定
├── src/
│ ├── routes/
│ │ ├── index.tsx
│ │ └── about.tsx
│ ├── entry-client.tsx
│ ├── entry-server.tsx
│ └── root.tsx
└── package.json
// app.config.ts(SolidStart内部でVinxi使用)
import { createApp } from 'vinxi';
import solid from 'vite-plugin-solid';
export default createApp({
routers: [
{
name: 'public',
type: 'static',
dir: './public'
},
{
name: 'client',
type: 'client',
handler: './src/entry-client.tsx',
target: 'browser',
plugins: () => [solid({ ssr: true })]
},
{
name: 'server',
type: 'http',
handler: './src/entry-server.tsx',
target: 'server'
}
]
});
Server Functions(SolidStart)
// src/api/users.ts
'use server';
import { db } from './db';
export async function getUsers() {
return db.query('SELECT * FROM users');
}
// src/routes/users.tsx
import { createAsync } from '@solidjs/router';
import { getUsers } from '../api/users';
export default function Users() {
const users = createAsync(() => getUsers());
return (
<div>
<h1>Users</h1>
<Suspense fallback={<p>Loading...</p>}>
<For each={users()}>
{user => <li>{user.name}</li>}
</For>
</Suspense>
</div>
);
}
TanStack Start統合
プロジェクト作成
npm create @tanstack/start@latest
? Project name: my-tanstack-app
? Use TypeScript: Yes
cd my-tanstack-app
npm install
Vinxi設定
// app.config.ts
import { createApp } from 'vinxi';
import { tanstackStart } from '@tanstack/start/vinxi';
export default createApp({
routers: [
tanstackStart({
routesDirectory: './app/routes'
})
]
});
ルート定義
// app/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/')({
component: Home
});
function Home() {
return <h1>Welcome to TanStack Start</h1>;
}
// app/routes/blog/$postId.tsx
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/blog/$postId')({
loader: async ({ params }) => {
const post = await fetch(`/api/posts/${params.postId}`).then(r => r.json());
return { post };
},
component: BlogPost
});
function BlogPost() {
const { post } = Route.useLoaderData();
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
パフォーマンス最適化
Code Splitting
// 自動コード分割(ルートベース)
// app/routes/heavy.tsx
import { lazy } from 'react';
const HeavyComponent = lazy(() => import('../components/HeavyComponent'));
export default function HeavyPage() {
return (
<Suspense fallback={<p>Loading...</p>}>
<HeavyComponent />
</Suspense>
);
}
Preloading
// app/routes/index.tsx
import { Link } from '@tanstack/react-router';
export default function Home() {
return (
<div>
<Link
to="/blog"
preload="intent" // ホバー時にプリロード
>
Blog
</Link>
</div>
);
}
ISR(Incremental Static Regeneration)
// app/routes/blog/index.tsx
export const Route = createFileRoute('/blog')({
loader: async () => {
const posts = await fetchPosts();
return { posts };
},
staleTime: 60000, // 60秒間キャッシュ
gcTime: 300000 // 5分後にガベージコレクション
});
ベストプラクティス
プロジェクト構造
project/
├── app.config.ts
├── src/
│ ├── routes/ # ファイルベースルーティング
│ ├── components/ # 共通コンポーネント
│ ├── api/ # Server Functions
│ ├── lib/ # ユーティリティ
│ ├── styles/ # スタイル
│ └── types/ # 型定義
├── public/ # 静的ファイル
└── .output/ # ビルド出力
型定義の共有
// src/types/user.ts
export interface User {
id: number;
name: string;
email: string;
}
export interface CreateUserInput {
name: string;
email: string;
}
// src/api/users.ts
'use server';
import type { User, CreateUserInput } from '../types/user';
export async function createUser(input: CreateUserInput): Promise<User> {
// サーバーロジック
}
// src/routes/users.tsx
import type { User } from '../types/user';
import { createUser } from '../api/users';
// クライアントコードでも同じ型を使用
const newUser: User = await createUser({ name: 'Alice', email: 'alice@example.com' });
エラーハンドリング
// app/routes/error-boundary.tsx
import { ErrorBoundary } from '@tanstack/react-router';
export default function App() {
return (
<ErrorBoundary
fallback={(error) => (
<div>
<h1>Error</h1>
<p>{error.message}</p>
</div>
)}
>
<Routes />
</ErrorBoundary>
);
}
実践例: フルスタックアプリ
タスク管理アプリ
// src/types/task.ts
export interface Task {
id: number;
title: string;
completed: boolean;
createdAt: Date;
}
// src/api/tasks.ts
'use server';
import { db } from './db';
import type { Task } from '../types/task';
export async function getTasks(): Promise<Task[]> {
return db.query('SELECT * FROM tasks ORDER BY createdAt DESC');
}
export async function createTask(title: string): Promise<Task> {
const result = await db.query(
'INSERT INTO tasks (title, completed) VALUES (?, ?)',
[title, false]
);
return {
id: result.insertId,
title,
completed: false,
createdAt: new Date()
};
}
export async function toggleTask(id: number): Promise<void> {
await db.query(
'UPDATE tasks SET completed = NOT completed WHERE id = ?',
[id]
);
}
export async function deleteTask(id: number): Promise<void> {
await db.query('DELETE FROM tasks WHERE id = ?', [id]);
}
// src/routes/index.tsx
import { useState } from 'react';
import { createAsync, revalidate } from 'vinxi/data';
import { getTasks, createTask, toggleTask, deleteTask } from '../api/tasks';
export default function Home() {
const tasks = createAsync(() => getTasks());
const [title, setTitle] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
await createTask(title);
setTitle('');
revalidate(getTasks.key);
}
async function handleToggle(id: number) {
await toggleTask(id);
revalidate(getTasks.key);
}
async function handleDelete(id: number) {
await deleteTask(id);
revalidate(getTasks.key);
}
return (
<div>
<h1>Task Manager</h1>
<form onSubmit={handleSubmit}>
<input
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="New task..."
/>
<button type="submit">Add</button>
</form>
<Suspense fallback={<p>Loading tasks...</p>}>
<ul>
{tasks()?.map(task => (
<li key={task.id}>
<input
type="checkbox"
checked={task.completed}
onChange={() => handleToggle(task.id)}
/>
<span style={{ textDecoration: task.completed ? 'line-through' : 'none' }}>
{task.title}
</span>
<button onClick={() => handleDelete(task.id)}>Delete</button>
</li>
))}
</ul>
</Suspense>
</div>
);
}
まとめ
Vinxiはメタフレームワーク基盤として、以下の価値を提供します。
主要な利点
- フレームワーク非依存 - React、Solid、Vue、任意のUIライブラリ対応
- Viteベース - 高速HMR、最新ツールチェーン
- Full-Stack型安全 - サーバー/クライアント間の完全な型共有
- モジュラー設計 - 必要な機能だけ選択可能
- 実績ある基盤 - SolidStart、TanStack Startで採用
採用判断基準
Vinxiを選ぶべき場合:
- 既存フレームワークに縛られたくない
- カスタムメタフレームワークを構築したい
- SolidStartやTanStack Startを使いたい
- Full-Stack型安全性が必要
他の選択肢を検討すべき場合:
- Next.jsの既存エコシステムを活用したい
- 安定性・成熟度優先(Next.js、Remixを選択)
- 学習コスト最小化(既存フレームワークのまま)
Vinxiは次世代のメタフレームワーク開発を加速させる強力な基盤であり、今後さらに多くのフレームワークがVinxi上に構築されることが期待されます。