Remix で管理画面テンプレ:表・フィルタ・CSV エクスポートの鉄板構成
管理画面を開発する際、データの一覧表示、フィルタリング、CSV エクスポートといった機能は必須の要件となることがほとんどです。Remix を使えば、これらの機能を効率的に実装できるだけでなく、優れた UX と SEO にも配慮した設計が可能になります。
本記事では、Remix の特性を最大限に活かした管理画面テンプレートの構築方法を、実装コード付きで詳しく解説していきます。初めて Remix で管理画面を作る方でも、この記事を読めばすぐに実践できる構成をご紹介いたします。
背景
管理画面に求められる機能要件
企業向けシステムや SaaS アプリケーションの管理画面には、共通して求められる機能があります。 それは「大量のデータを効率的に閲覧・検索し、必要に応じてエクスポートできること」です。
管理画面の典型的なユースケースとして、以下が挙げられるでしょう:
- ユーザー情報の一覧表示と検索
- 注文履歴のフィルタリングと抽出
- ログデータの期間指定とダウンロード
Remix が管理画面開発に適している理由
Remix は Next.js とは異なるアプローチで、サーバーとクライアントのデータフローを設計しています。 特に管理画面開発において、以下の特徴が大きなメリットとなるのです:
| # | 特徴 | メリット |
|---|---|---|
| 1 | Loader による SSR データ取得 | 初期表示が高速で SEO にも有利 |
| 2 | Form と Action の統合 | フィルタリング処理がシンプルに実装可能 |
| 3 | URL 連動の自然な状態管理 | ブックマークや共有が容易 |
| 4 | Resource Routes | CSV エクスポートなどの非 HTML レスポンスに最適 |
以下の図は、Remix における管理画面のデータフローを示したものです。
mermaidflowchart TB
user["管理者"] -->|"ページアクセス"| loader["Loader 関数"]
loader -->|"DB クエリ"| db[("データベース")]
db -->|"データ返却"| loader
loader -->|"SSR"| page["管理画面ページ"]
page -->|"表示"| user
user -->|"フィルタ送信"| form["Form"]
form -->|"URL パラメータ"| loader
user -->|"CSV 出力"| resource["Resource Route"]
resource -->|"クエリ"| db
db -->|"データ"| resource
resource -->|"CSV ファイル"| user
この図から分かるように、Remix では Loader がデータ取得の中心となり、Form による操作が自然に URL パラメータへ反映される設計になっています。
図で理解できる要点:
- Loader 関数がサーバーサイドでデータを取得し SSR で配信
- Form 送信が URL パラメータとして Loader へ自動的に渡される
- Resource Route により CSV などの非 HTML レスポンスも簡単に実装可能
課題
従来の管理画面実装における問題点
管理画面を実装する際、多くの開発者が直面する課題があります。 それは「状態管理の複雑化」と「パフォーマンスの劣化」です。
従来の SPA アプローチでは、以下のような問題が発生しがちでした:
| # | 課題 | 影響 |
|---|---|---|
| 1 | クライアント側の状態管理が複雑 | フィルタ条件、ページネーション、ソート順などを Redux や Context で管理する必要がある |
| 2 | 初期ロードの遅延 | JavaScript バンドルのダウンロード後にデータフェッチが始まる |
| 3 | URL との同期が困難 | ブックマークや共有時に状態が再現されない |
| 4 | エクスポート処理の実装コスト | クライアント側で大量データを処理するか、別途 API を用意する必要がある |
データ量増加に伴うパフォーマンス問題
管理画面のデータ量が増えるにつれ、以下のパフォーマンス問題が顕在化します:
- 数千件のレコードをクライアント側で保持すると、メモリ使用量が増大する
- フィルタリングやソート処理がブラウザ上で実行されるため、UI がフリーズしやすくなる
- CSV エクスポート時にブラウザのメモリ制限に達してしまう
これらの課題を解決するには、サーバー側でのデータ処理と URL ベースの状態管理が鍵となるのです。
以下の図は、従来の SPA 方式と Remix 方式の処理フローの違いを示しています。
mermaidflowchart LR
subgraph spa["従来の SPA 方式"]
direction TB
spa_user["ユーザー"] -->|"アクセス"| spa_load["JS バンドル<br/>ダウンロード"]
spa_load -->|"実行"| spa_fetch["API フェッチ"]
spa_fetch -->|"データ取得"| spa_render["クライアント<br/>レンダリング"]
spa_render -->|"表示"| spa_user
end
subgraph remix["Remix 方式"]
direction TB
remix_user["ユーザー"] -->|"アクセス"| remix_loader["Loader で<br/>データ取得"]
remix_loader -->|"SSR"| remix_render["HTML 配信"]
remix_render -->|"即座に表示"| remix_user
end
図で理解できる要点:
- SPA 方式では JS ダウンロード → 実行 → API フェッチの順で処理されるため初期表示が遅い
- Remix 方式では Loader が事前にデータを取得し SSR で配信するため即座に表示可能
- Remix は URL パラメータで状態を管理するため、ブックマークや共有が容易
解決策
Remix の Loader と Form を活用した設計
Remix では、Loader 関数でサーバー側のデータ取得を行い、Form コンポーネントで URL パラメータを自動管理できます。 この仕組みを活用することで、状態管理のコードをほとんど書かずに、フィルタリング機能を実装できるのです。
基本的な設計方針は以下の通りです:
| # | 機能 | 実装方法 |
|---|---|---|
| 1 | データ一覧表示 | Loader 関数で DB からデータ取得、useLoaderData でコンポーネントに渡す |
| 2 | フィルタリング | Form の送信で URL パラメータを更新、Loader が自動的に再実行される |
| 3 | CSV エクスポート | Resource Route で CSV ファイルを生成し、ダウンロード用エンドポイントを提供 |
Resource Route による CSV エクスポート
Remix の Resource Route は、HTML 以外のレスポンスを返すための機能です。 CSV エクスポート機能を実装する際に、この Resource Route を活用すると非常にシンプルなコードで実現できます。
通常の Route では JSX を返しますが、Resource Route では任意の Response オブジェクトを返せるため、CSV ファイルのダウンロードに最適なのです。
以下の図は、Remix 管理画面の全体アーキテクチャを示しています。
mermaidflowchart TB
route["routes/admin/users.tsx"]
resource["routes/admin/users/export.csv.tsx"]
route -->|"Loader"| db1[("DB")]
route -->|"useLoaderData"| table["テーブル<br/>コンポーネント"]
route -->|"Form"| filter["フィルタ<br/>コンポーネント"]
filter -->|"submit"| url["URL パラメータ<br/>更新"]
url -->|"再実行"| route
resource -->|"Loader"| db2[("DB")]
resource -->|"Response"| csv["CSV ファイル"]
table -->|"エクスポート<br/>ボタン"| resource
図で理解できる要点:
- 通常の Route で一覧表示とフィルタリングを担当
- Resource Route で CSV エクスポート専用のエンドポイントを提供
- Form 送信により URL パラメータが更新され、Loader が自動的に再実行される
具体例
プロジェクトのセットアップ
まず、Remix プロジェクトを作成し、必要なパッケージをインストールします。
bash# Remix プロジェクトの作成
npx create-remix@latest my-admin-panel
# プロジェクトディレクトリへ移動
cd my-admin-panel
# 必要なパッケージのインストール
yarn add date-fns prisma @prisma/client
yarn add -D @types/node
次に、Prisma を初期化してデータベーススキーマを定義しましょう。
bash# Prisma の初期化
npx prisma init
データベーススキーマの定義
データベーススキーマを定義します。 今回はユーザー管理を例に、シンプルな構成で実装していきます。
prisma// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
User モデルを定義します。
prisma// prisma/schema.prisma (続き)
model User {
id String @id @default(cuid())
email String @unique
name String
role String // "admin" | "user" | "guest"
status String // "active" | "inactive"
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
スキーマ定義後、マイグレーションを実行します。
bash# マイグレーションの実行
npx prisma migrate dev --name init
# Prisma Client の生成
npx prisma generate
型定義とユーティリティ関数
型定義ファイルを作成し、フィルタ条件やユーザーデータの型を定義します。
typescript// app/types/user.ts
export type UserRole = 'admin' | 'user' | 'guest';
export type UserStatus = 'active' | 'inactive';
フィルタ条件の型を定義します。
typescript// app/types/user.ts (続き)
export interface UserFilter {
keyword?: string; // 名前やメールで検索
role?: UserRole; // ロールでフィルタ
status?: UserStatus; // ステータスでフィルタ
page?: number; // ページ番号
limit?: number; // 1ページあたりの件数
}
Loader 関数の実装
管理画面のメインとなる Route ファイルを作成し、Loader 関数を実装します。 Loader 関数では、URL パラメータからフィルタ条件を取得し、データベースへクエリを実行します。
typescript// app/routes/admin.users.tsx
import {
json,
type LoaderFunctionArgs,
} from '@remix-run/node';
import { useLoaderData, Form } from '@remix-run/react';
import { PrismaClient } from '@prisma/client';
import type { UserFilter } from '~/types/user';
const prisma = new PrismaClient();
URL パラメータからフィルタ条件を抽出する関数を実装します。
typescript// app/routes/admin.users.tsx (続き)
/**
* URL パラメータからフィルタ条件を抽出
*/
function getFilterFromSearchParams(
searchParams: URLSearchParams
): UserFilter {
return {
keyword: searchParams.get('keyword') || undefined,
role:
(searchParams.get('role') as UserFilter['role']) ||
undefined,
status:
(searchParams.get(
'status'
) as UserFilter['status']) || undefined,
page: Number(searchParams.get('page')) || 1,
limit: Number(searchParams.get('limit')) || 20,
};
}
Loader 関数を実装します。この関数がサーバー側でデータを取得し、SSR で配信します。
typescript// app/routes/admin.users.tsx (続き)
export async function loader({
request,
}: LoaderFunctionArgs) {
// URL パラメータを取得
const url = new URL(request.url);
const filter = getFilterFromSearchParams(
url.searchParams
);
// ページネーション用の offset を計算
const offset =
((filter.page || 1) - 1) * (filter.limit || 20);
// WHERE 条件を構築
const where: any = {};
// キーワード検索: 名前またはメールに部分一致
if (filter.keyword) {
where.OR = [
{ name: { contains: filter.keyword } },
{ email: { contains: filter.keyword } },
];
}
// ロールでフィルタ
if (filter.role) {
where.role = filter.role;
}
// ステータスでフィルタ
if (filter.status) {
where.status = filter.status;
}
// データ取得と総件数の取得を並行実行
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
skip: offset,
take: filter.limit,
orderBy: { createdAt: 'desc' },
}),
prisma.user.count({ where }),
]);
return json({ users, total, filter });
}
フィルタコンポーネントの実装
フィルタ用のコンポーネントを作成します。 Remix の Form コンポーネントを使用することで、送信時に自動的に URL パラメータが更新されます。
typescript// app/routes/admin.users.tsx (続き)
/**
* フィルタフォームコンポーネント
* Form コンポーネントを使うことで、送信時に URL パラメータが自動更新される
*/
function UserFilter({ filter }: { filter: UserFilter }) {
return (
<Form method='get' className='filter-form'>
<div className='filter-row'>
{/* キーワード検索 */}
<label>
<span>キーワード検索</span>
<input
type='text'
name='keyword'
defaultValue={filter.keyword}
placeholder='名前またはメール'
/>
</label>
{/* ロールでフィルタ */}
<label>
<span>ロール</span>
<select name='role' defaultValue={filter.role}>
<option value=''>すべて</option>
<option value='admin'>管理者</option>
<option value='user'>一般ユーザー</option>
<option value='guest'>ゲスト</option>
</select>
</label>
{/* ステータスでフィルタ */}
<label>
<span>ステータス</span>
<select
name='status'
defaultValue={filter.status}
>
<option value=''>すべて</option>
<option value='active'>有効</option>
<option value='inactive'>無効</option>
</select>
</label>
</div>
{/* 検索ボタン */}
<div className='filter-actions'>
<button type='submit'>検索</button>
<a href='/admin/users'>リセット</a>
</div>
</Form>
);
}
テーブルコンポーネントの実装
データ一覧を表示するテーブルコンポーネントを実装します。
typescript// app/routes/admin.users.tsx (続き)
/**
* ユーザー一覧テーブルコンポーネント
*/
function UserTable({ users }: { users: any[] }) {
return (
<table className='data-table'>
<thead>
<tr>
<th>ID</th>
<th>名前</th>
<th>メール</th>
<th>ロール</th>
<th>ステータス</th>
<th>登録日</th>
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr>
<td colSpan={6} className='no-data'>
データがありません
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id}>
<td>{user.id}</td>
<td>{user.name}</td>
<td>{user.email}</td>
<td>{user.role}</td>
<td>{user.status}</td>
<td>
{new Date(
user.createdAt
).toLocaleDateString()}
</td>
</tr>
))
)}
</tbody>
</table>
);
}
ページネーションコンポーネントの実装
ページネーション機能を実装します。 URL パラメータの page を更新することで、Loader が自動的に再実行されます。
typescript// app/routes/admin.users.tsx (続き)
/**
* ページネーションコンポーネント
*/
function Pagination({
total,
filter,
}: {
total: number;
filter: UserFilter;
}) {
const currentPage = filter.page || 1;
const limit = filter.limit || 20;
const totalPages = Math.ceil(total / limit);
// 現在のフィルタ条件を保持したまま、page パラメータだけを変更する関数
const buildPageUrl = (page: number) => {
const params = new URLSearchParams();
if (filter.keyword)
params.set('keyword', filter.keyword);
if (filter.role) params.set('role', filter.role);
if (filter.status) params.set('status', filter.status);
params.set('page', String(page));
params.set('limit', String(limit));
return `?${params.toString()}`;
};
return (
<div className='pagination'>
<span>
全 {total} 件中 {(currentPage - 1) * limit + 1} -{' '}
{Math.min(currentPage * limit, total)} 件を表示
</span>
<div className='pagination-buttons'>
{/* 前へボタン */}
{currentPage > 1 && (
<a href={buildPageUrl(currentPage - 1)}>前へ</a>
)}
{/* ページ番号 */}
{Array.from(
{ length: totalPages },
(_, i) => i + 1
).map((page) => (
<a
key={page}
href={buildPageUrl(page)}
className={page === currentPage ? 'active' : ''}
>
{page}
</a>
))}
{/* 次へボタン */}
{currentPage < totalPages && (
<a href={buildPageUrl(currentPage + 1)}>次へ</a>
)}
</div>
</div>
);
}
メインコンポーネントの実装
最後に、すべてのコンポーネントを組み合わせたメインコンポーネントを実装します。
typescript// app/routes/admin.users.tsx (続き)
/**
* メインコンポーネント
*/
export default function AdminUsers() {
// Loader から取得したデータを useLoaderData で受け取る
const { users, total, filter } =
useLoaderData<typeof loader>();
return (
<div className='admin-users'>
<h1>ユーザー管理</h1>
{/* フィルタフォーム */}
<UserFilter filter={filter} />
{/* CSV エクスポートボタン */}
<div className='export-actions'>
<a
href='/admin/users/export.csv'
download='users.csv'
className='export-button'
>
CSV エクスポート
</a>
</div>
{/* データテーブル */}
<UserTable users={users} />
{/* ページネーション */}
<Pagination total={total} filter={filter} />
</div>
);
}
CSV エクスポート用 Resource Route の実装
CSV エクスポート機能を Resource Route として実装します。 Resource Route は HTML ではなく、CSV ファイルを返すためのエンドポイントです。
typescript// app/routes/admin.users.export.csv.tsx
import type { LoaderFunctionArgs } from '@remix-run/node';
import { PrismaClient } from '@prisma/client';
import { format } from 'date-fns';
import type { UserFilter } from '~/types/user';
const prisma = new PrismaClient();
URL パラメータからフィルタ条件を抽出する関数を再利用します。
typescript// app/routes/admin.users.export.csv.tsx (続き)
/**
* URL パラメータからフィルタ条件を抽出
*/
function getFilterFromSearchParams(
searchParams: URLSearchParams
): UserFilter {
return {
keyword: searchParams.get('keyword') || undefined,
role:
(searchParams.get('role') as UserFilter['role']) ||
undefined,
status:
(searchParams.get(
'status'
) as UserFilter['status']) || undefined,
};
}
CSV 文字列を生成する関数を実装します。
typescript// app/routes/admin.users.export.csv.tsx (続き)
/**
* ユーザーデータを CSV 形式に変換
*/
function convertToCSV(users: any[]): string {
// CSV ヘッダー行
const headers = [
'ID',
'名前',
'メール',
'ロール',
'ステータス',
'登録日',
];
// データ行を作成
const rows = users.map((user) => [
user.id,
user.name,
user.email,
user.role,
user.status,
format(new Date(user.createdAt), 'yyyy-MM-dd HH:mm:ss'),
]);
// ヘッダーとデータを結合
const allRows = [headers, ...rows];
// CSV 文字列に変換(各フィールドをダブルクォートで囲む)
return allRows
.map((row) => row.map((cell) => `"${cell}"`).join(','))
.join('\n');
}
Loader 関数を実装し、CSV ファイルをレスポンスとして返します。
typescript// app/routes/admin.users.export.csv.tsx (続き)
/**
* CSV エクスポート用の Loader 関数
* Response オブジェクトを返すことで、CSV ファイルをダウンロードさせる
*/
export async function loader({
request,
}: LoaderFunctionArgs) {
// URL パラメータを取得
const url = new URL(request.url);
const filter = getFilterFromSearchParams(
url.searchParams
);
// WHERE 条件を構築(一覧画面と同じロジック)
const where: any = {};
if (filter.keyword) {
where.OR = [
{ name: { contains: filter.keyword } },
{ email: { contains: filter.keyword } },
];
}
if (filter.role) {
where.role = filter.role;
}
if (filter.status) {
where.status = filter.status;
}
// データを取得(エクスポートでは全件取得)
const users = await prisma.user.findMany({
where,
orderBy: { createdAt: 'desc' },
});
// CSV に変換
const csv = convertToCSV(users);
// BOM 付き UTF-8 で CSV を返す(Excel で文字化けしないため)
const bom = '\uFEFF';
const content = bom + csv;
// Response オブジェクトを返す
return new Response(content, {
status: 200,
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="users_${format(
new Date(),
'yyyyMMdd_HHmmss'
)}.csv"`,
},
});
}
スタイリングの追加
管理画面らしい見た目にするため、基本的なスタイルを追加します。
css/* app/styles/admin.css */
.admin-users {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.admin-users h1 {
font-size: 24px;
margin-bottom: 20px;
}
フィルタフォームのスタイルを定義します。
css/* app/styles/admin.css (続き) */
.filter-form {
background: #f5f5f5;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.filter-row {
display: grid;
grid-template-columns: repeat(
auto-fit,
minmax(200px, 1fr)
);
gap: 15px;
margin-bottom: 15px;
}
.filter-row label {
display: flex;
flex-direction: column;
gap: 5px;
}
.filter-row label span {
font-weight: bold;
font-size: 14px;
}
.filter-row input,
.filter-row select {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
アクションボタンとテーブルのスタイルを定義します。
css/* app/styles/admin.css (続き) */
.filter-actions {
display: flex;
gap: 10px;
}
.filter-actions button,
.export-button {
padding: 10px 20px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
.filter-actions a {
padding: 10px 20px;
background: #6c757d;
color: white;
text-decoration: none;
border-radius: 4px;
}
.export-actions {
margin-bottom: 20px;
}
.data-table {
width: 100%;
border-collapse: collapse;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.data-table th,
.data-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.data-table th {
background: #f8f9fa;
font-weight: bold;
}
.data-table tbody tr:hover {
background: #f5f5f5;
}
.no-data {
text-align: center;
color: #999;
padding: 40px !important;
}
ページネーションのスタイルを定義します。
css/* app/styles/admin.css (続き) */
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
}
.pagination-buttons {
display: flex;
gap: 5px;
}
.pagination-buttons a {
padding: 8px 12px;
background: white;
border: 1px solid #ddd;
text-decoration: none;
color: #333;
border-radius: 4px;
}
.pagination-buttons a.active {
background: #007bff;
color: white;
border-color: #007bff;
}
.pagination-buttons a:hover:not(.active) {
background: #f5f5f5;
}
動作確認とテスト
実装が完了したら、開発サーバーを起動して動作を確認しましょう。
bash# 開発サーバーの起動
yarn dev
ブラウザで http://localhost:3000/admin/users にアクセスし、以下の機能が正しく動作することを確認します:
| # | 確認項目 | 期待される動作 |
|---|---|---|
| 1 | 初期表示 | ユーザー一覧が表示される |
| 2 | キーワード検索 | 名前またはメールで部分一致検索できる |
| 3 | ロールフィルタ | ロールでフィルタリングできる |
| 4 | ステータスフィルタ | ステータスでフィルタリングできる |
| 5 | ページネーション | ページを切り替えられる |
| 6 | CSV エクスポート | フィルタ条件を反映した CSV がダウンロードされる |
| 7 | URL 連動 | フィルタ条件が URL に反映され、リロードしても状態が保持される |
以下の図は、実装した管理画面の状態遷移を示しています。
mermaidstateDiagram-v2
[*] --> initial_load: ページアクセス
initial_load --> display_data: Loader 実行
display_data --> filter_submit: フィルタ送信
filter_submit --> url_update: URL パラメータ更新
url_update --> display_data: Loader 再実行
display_data --> page_change: ページ変更
page_change --> url_update
display_data --> csv_export: CSV 出力
csv_export --> download: ファイル DL
download --> display_data
display_data --> [*]
図で理解できる要点:
- ページアクセス時に Loader が実行されデータが表示される
- フィルタ送信やページ変更により URL パラメータが更新され、Loader が自動的に再実行される
- CSV エクスポートは Resource Route により独立して処理される
まとめ
Remix を使った管理画面テンプレートの実装方法をご紹介しました。
本記事で解説した内容を振り返ると、以下のポイントが重要です:
実装のポイント:
- Loader 関数で SSR によるデータ取得を行い、初期表示を高速化する
- Form コンポーネントで URL パラメータを自動管理し、状態管理コードを削減する
- Resource Route で CSV エクスポートなどの非 HTML レスポンスをシンプルに実装する
- URL ベースの状態管理により、ブックマークや共有が容易になる
得られるメリット:
| # | メリット | 効果 |
|---|---|---|
| 1 | コード量の削減 | 状態管理ライブラリが不要になり、実装コードが大幅に削減される |
| 2 | パフォーマンス向上 | SSR により初期表示が高速化し、大量データも効率的に処理できる |
| 3 | 保守性の向上 | URL と状態が連動するため、デバッグやテストが容易になる |
| 4 | UX の改善 | ブラウザの戻る・進むボタンが正しく動作し、URL の共有が可能になる |
この実装パターンは、ユーザー管理に限らず、注文履歴、ログ閲覧、商品管理など、あらゆる管理画面に応用できます。 Remix の特性を理解し、適切に活用することで、効率的かつ保守性の高い管理画面を構築できるでしょう。
ぜひこのテンプレートをベースに、プロジェクトに合わせてカスタマイズしてみてください。
関連リンク
articleRemix で管理画面テンプレ:表・フィルタ・CSV エクスポートの鉄板構成
articleRemix でブログをゼロから構築:Markdown・検索・タグ・OGP まで実装
articleRemix でスケーラブルなディレクトリ設計:routes/リソース/ユーティリティ分割
articleRemix ルーティング早見表:ネスト・可変パラメータ・モーダルルート対応一覧
articleRemix 最短セットアップ:初期化から初デプロイまで 10 分で完走する手順
articleRemix と Next.js/Vite/徹底比較:選ぶべきポイントはここだ!
articleSvelte のコンパイル出力を読み解く:仮想 DOM なしで速い理由
articleTauri で Markdown エディタを作る:ライブプレビュー・拡張プラグイン対応
articleStorybook で“仕様が生きる”開発:ドキュメント駆動 UI の実践ロードマップ
articleshadcn/ui で B2B SaaS ダッシュボードを組む:権限別 UI と監査ログの見せ方
articleSolidJS の Control Flow コンポーネント大全:Show/For/Switch/ErrorBoundary を使い分け
articleRemix で管理画面テンプレ:表・フィルタ・CSV エクスポートの鉄板構成
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来