CRUD アプリケーション構築
HonoXでの学習の集大成として、実際のCRUD(Create、Read、Update、Delete)アプリケーションを構築してみましょう。ここまで学んだSSR、CSR、型安全性、バリデーション等の知識を組み合わせて、実用的なブログ管理システムを作成します。
プロジェクト概要
構築するアプリケーション
ブログ管理システム
- 記事の作成・編集・削除・公開
- タグ管理機能
- 著者管理
- コメント機能
- 管理画面
技術スタック
{
"framework": "HonoX",
"runtime": "Node.js / Cloudflare Workers",
"database": "SQLite / PostgreSQL",
"validation": "Zod",
"styling": "Tailwind CSS",
"authentication": "JWT",
"testing": "Vitest"
}
データベース設計
スキーマ定義
-- データベーススキーマ
-- schema.sql
-- ユーザー(著者)
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
avatar_url TEXT,
bio TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- 投稿
CREATE TABLE posts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
content TEXT NOT NULL,
excerpt TEXT,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published')),
author_id TEXT NOT NULL REFERENCES users(id),
published_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- タグ
CREATE TABLE tags (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
slug TEXT UNIQUE NOT NULL,
color TEXT DEFAULT '#3B82F6',
created_at TEXT NOT NULL
);
-- 投稿とタグの関連
CREATE TABLE post_tags (
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
-- コメント
CREATE TABLE comments (
id TEXT PRIMARY KEY,
post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
author_name TEXT NOT NULL,
author_email TEXT NOT NULL,
content TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
created_at TEXT NOT NULL
);
-- インデックス
CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_posts_status ON posts(status);
CREATE INDEX idx_posts_published_at ON posts(published_at);
CREATE INDEX idx_comments_post_id ON comments(post_id);
CREATE INDEX idx_comments_status ON comments(status);
データベース操作クラス
// app/lib/database/connection.ts
import Database from 'better-sqlite3'
class DatabaseManager {
private static instance: DatabaseManager
private db: Database.Database
private constructor() {
this.db = new Database(process.env.DATABASE_PATH || './blog.db')
this.initializeSchema()
}
static getInstance(): DatabaseManager {
if (!DatabaseManager.instance) {
DatabaseManager.instance = new DatabaseManager()
}
return DatabaseManager.instance
}
private initializeSchema() {
// スキーマファイルを読み込んで実行
const schema = readFileSync('./schema.sql', 'utf-8')
this.db.exec(schema)
}
getDb(): Database.Database {
return this.db
}
query(sql: string, params: any[] = []): any[] {
try {
const stmt = this.db.prepare(sql)
return stmt.all(...params)
} catch (error) {
console.error('Database query error:', error)
throw error
}
}
run(sql: string, params: any[] = []): Database.RunResult {
try {
const stmt = this.db.prepare(sql)
return stmt.run(...params)
} catch (error) {
console.error('Database run error:', error)
throw error
}
}
transaction<T>(fn: () => T): T {
return this.db.transaction(fn)()
}
}
export const db = DatabaseManager.getInstance()
モデル層の実装
Post モデル
// app/lib/models/Post.ts
import { db } from '../database/connection'
import { z } from 'zod'
export interface Post {
id: string
title: string
slug: string
content: string
excerpt: string | null
status: 'draft' | 'published'
authorId: string
author?: User
tags?: Tag[]
publishedAt: string | null
createdAt: string
updatedAt: string
}
export interface CreatePostData {
title: string
content: string
excerpt?: string
status?: 'draft' | 'published'
authorId: string
tags?: string[]
}
export class PostModel {
// 投稿一覧取得
static async getList(options: {
page?: number
limit?: number
status?: 'draft' | 'published'
authorId?: string
tag?: string
} = {}): Promise<{ posts: Post[], total: number }> {
const {
page = 1,
limit = 10,
status,
authorId,
tag
} = options
let whereClause = '1=1'
const params: any[] = []
if (status) {
whereClause += ' AND p.status = ?'
params.push(status)
}
if (authorId) {
whereClause += ' AND p.author_id = ?'
params.push(authorId)
}
if (tag) {
whereClause += ` AND EXISTS (
SELECT 1 FROM post_tags pt
JOIN tags t ON pt.tag_id = t.id
WHERE pt.post_id = p.id AND t.slug = ?
)`
params.push(tag)
}
// 総数取得
const countQuery = `
SELECT COUNT(*) as total
FROM posts p
WHERE ${whereClause}
`
const [countResult] = db.query(countQuery, params)
const total = countResult.total
// データ取得
const offset = (page - 1) * limit
const dataQuery = `
SELECT
p.*,
u.name as author_name,
u.avatar_url as author_avatar
FROM posts p
LEFT JOIN users u ON p.author_id = u.id
WHERE ${whereClause}
ORDER BY
CASE WHEN p.published_at IS NOT NULL THEN p.published_at ELSE p.created_at END DESC
LIMIT ? OFFSET ?
`
const posts = db.query(dataQuery, [...params, limit, offset])
// タグ情報を別途取得
const postsWithTags = await Promise.all(
posts.map(async (post) => ({
...post,
tags: await this.getPostTags(post.id)
}))
)
return { posts: postsWithTags, total }
}
// 投稿詳細取得
static async getById(id: string): Promise<Post | null> {
const query = `
SELECT
p.*,
u.name as author_name,
u.email as author_email,
u.avatar_url as author_avatar,
u.bio as author_bio
FROM posts p
LEFT JOIN users u ON p.author_id = u.id
WHERE p.id = ?
`
const [post] = db.query(query, [id])
if (!post) return null
const tags = await this.getPostTags(id)
return { ...post, tags }
}
// スラッグで取得
static async getBySlug(slug: string): Promise<Post | null> {
const query = `
SELECT
p.*,
u.name as author_name,
u.avatar_url as author_avatar
FROM posts p
LEFT JOIN users u ON p.author_id = u.id
WHERE p.slug = ? AND p.status = 'published'
`
const [post] = db.query(query, [slug])
if (!post) return null
const tags = await this.getPostTags(post.id)
return { ...post, tags }
}
// 投稿作成
static async create(data: CreatePostData): Promise<Post> {
const id = crypto.randomUUID()
const slug = this.generateSlug(data.title)
const now = new Date().toISOString()
const publishedAt = data.status === 'published' ? now : null
return db.transaction(() => {
// 投稿を作成
db.run(`
INSERT INTO posts (id, title, slug, content, excerpt, status, author_id, published_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
id, data.title, slug, data.content, data.excerpt || null,
data.status || 'draft', data.authorId, publishedAt, now, now
])
// タグを関連付け
if (data.tags && data.tags.length > 0) {
this.associateTags(id, data.tags)
}
return this.getById(id)!
})
}
// 投稿更新
static async update(id: string, data: Partial<CreatePostData>, userId: string): Promise<Post | null> {
// 権限チェック
const [existingPost] = db.query('SELECT author_id FROM posts WHERE id = ?', [id])
if (!existingPost || existingPost.author_id !== userId) {
return null
}
const now = new Date().toISOString()
const updates: string[] = []
const params: any[] = []
if (data.title) {
updates.push('title = ?', 'slug = ?')
params.push(data.title, this.generateSlug(data.title))
}
if (data.content !== undefined) {
updates.push('content = ?')
params.push(data.content)
}
if (data.excerpt !== undefined) {
updates.push('excerpt = ?')
params.push(data.excerpt)
}
if (data.status) {
updates.push('status = ?')
params.push(data.status)
if (data.status === 'published' && !existingPost.published_at) {
updates.push('published_at = ?')
params.push(now)
}
}
updates.push('updated_at = ?')
params.push(now, id)
return db.transaction(() => {
// 投稿を更新
if (updates.length > 1) {
db.run(`UPDATE posts SET ${updates.join(', ')} WHERE id = ?`, params)
}
// タグを更新
if (data.tags !== undefined) {
// 既存のタグ関連を削除
db.run('DELETE FROM post_tags WHERE post_id = ?', [id])
// 新しいタグを関連付け
if (data.tags.length > 0) {
this.associateTags(id, data.tags)
}
}
return this.getById(id)
})
}
// 投稿削除
static async delete(id: string, userId: string): Promise<boolean> {
// 権限チェック
const [existingPost] = db.query('SELECT author_id FROM posts WHERE id = ?', [id])
if (!existingPost || existingPost.author_id !== userId) {
return false
}
const result = db.run('DELETE FROM posts WHERE id = ?', [id])
return result.changes > 0
}
// スラッグ生成
private static generateSlug(title: string): string {
let baseSlug = title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.trim()
// 重複チェック
let slug = baseSlug
let counter = 1
while (true) {
const [existing] = db.query('SELECT id FROM posts WHERE slug = ?', [slug])
if (!existing) break
slug = `${baseSlug}-${counter}`
counter++
}
return slug
}
// タグの関連付け
private static associateTags(postId: string, tagNames: string[]) {
for (const tagName of tagNames) {
// タグが存在するか確認、なければ作成
let [tag] = db.query('SELECT id FROM tags WHERE name = ?', [tagName])
if (!tag) {
const tagId = crypto.randomUUID()
const tagSlug = tagName.toLowerCase().replace(/\s+/g, '-')
db.run(`
INSERT INTO tags (id, name, slug, created_at)
VALUES (?, ?, ?, ?)
`, [tagId, tagName, tagSlug, new Date().toISOString()])
tag = { id: tagId }
}
// 投稿とタグを関連付け
db.run(`
INSERT OR IGNORE INTO post_tags (post_id, tag_id)
VALUES (?, ?)
`, [postId, tag.id])
}
}
// 投稿のタグ取得
private static async getPostTags(postId: string): Promise<Tag[]> {
return db.query(`
SELECT t.id, t.name, t.slug, t.color
FROM tags t
JOIN post_tags pt ON t.id = pt.tag_id
WHERE pt.post_id = ?
ORDER BY t.name
`, [postId])
}
}
API層の実装
Posts API
// app/routes/api/posts/index.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { PostModel } from '../../../lib/models/Post'
import { PostSchemas } from '../../../lib/schemas/posts'
import { authMiddleware } from '../../../lib/middleware/auth'
const app = new Hono()
// 投稿一覧取得
app.get('/', zValidator('query', PostSchemas.listQuery), async (c) => {
const query = c.req.valid('query')
try {
const { posts, total } = await PostModel.getList(query)
return c.json({
success: true,
data: posts,
pagination: {
page: query.page,
limit: query.limit,
total,
totalPages: Math.ceil(total / query.limit)
}
})
} catch (error) {
return c.json({
success: false,
error: 'Failed to fetch posts'
}, 500)
}
})
// 投稿作成
app.post('/',
authMiddleware,
zValidator('json', PostSchemas.create),
async (c) => {
const postData = c.req.valid('json')
const user = c.get('user')
try {
const post = await PostModel.create({
...postData,
authorId: user.id
})
return c.json({
success: true,
data: post
}, 201)
} catch (error) {
return c.json({
success: false,
error: 'Failed to create post'
}, 500)
}
}
)
export default app
// app/routes/api/posts/[id].ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { PostModel } from '../../../lib/models/Post'
import { PostSchemas } from '../../../lib/schemas/posts'
import { authMiddleware } from '../../../lib/middleware/auth'
const app = new Hono()
// 投稿取得
app.get('/', async (c) => {
const id = c.req.param('id')
try {
const post = await PostModel.getById(id)
if (!post) {
return c.json({
success: false,
error: 'Post not found'
}, 404)
}
return c.json({
success: true,
data: post
})
} catch (error) {
return c.json({
success: false,
error: 'Failed to fetch post'
}, 500)
}
})
// 投稿更新
app.put('/',
authMiddleware,
zValidator('json', PostSchemas.update),
async (c) => {
const id = c.req.param('id')
const updateData = c.req.valid('json')
const user = c.get('user')
try {
const post = await PostModel.update(id, updateData, user.id)
if (!post) {
return c.json({
success: false,
error: 'Post not found or unauthorized'
}, 404)
}
return c.json({
success: true,
data: post
})
} catch (error) {
return c.json({
success: false,
error: 'Failed to update post'
}, 500)
}
}
)
// 投稿削除
app.delete('/', authMiddleware, async (c) => {
const id = c.req.param('id')
const user = c.get('user')
try {
const success = await PostModel.delete(id, user.id)
if (!success) {
return c.json({
success: false,
error: 'Post not found or unauthorized'
}, 404)
}
return c.json({
success: true,
message: 'Post deleted successfully'
})
} catch (error) {
return c.json({
success: false,
error: 'Failed to delete post'
}, 500)
}
})
export default app
フロントエンド実装
投稿一覧ページ
// app/routes/blog/index.tsx
import { PostCard } from '../../components/blog/PostCard'
import { Pagination } from '../../components/ui/Pagination'
interface BlogIndexProps {
page?: string
tag?: string
}
export default async function BlogIndex({ page = '1', tag }: BlogIndexProps) {
const currentPage = parseInt(page)
const limit = 12
// サーバーサイドでデータ取得
const { posts, pagination } = await getPostsWithPagination({
page: currentPage,
limit,
tag,
status: 'published'
})
return (
<div className="max-w-6xl mx-auto px-4 py-12">
<header className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
{tag ? `タグ: ${tag}` : 'ブログ'}
</h1>
<p className="text-lg text-gray-600">
Web開発とテクノロジーについての記事を書いています
</p>
</header>
{posts.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-600">記事が見つかりませんでした。</p>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-12">
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
basePath={tag ? `/blog?tag=${tag}` : '/blog'}
/>
</>
)}
</div>
)
}
async function getPostsWithPagination(params: any) {
// 実際のデータフェッチング実装
const { posts, total } = await PostModel.getList(params)
return {
posts,
pagination: {
page: params.page,
limit: params.limit,
total,
totalPages: Math.ceil(total / params.limit)
}
}
}
投稿詳細ページ
// app/routes/blog/[slug].tsx
import { CommentSection } from '../../islands/blog/CommentSection'
import { ShareButtons } from '../../islands/blog/ShareButtons'
import { RelatedPosts } from '../../islands/blog/RelatedPosts'
interface BlogPostProps {
slug: string
}
export default async function BlogPost({ slug }: BlogPostProps) {
// サーバーサイドで投稿を取得
const post = await PostModel.getBySlug(slug)
if (!post) {
return <BlogPostNotFound />
}
// 関連投稿も取得
const relatedPosts = await getRelatedPosts(post.id, post.tags?.map(t => t.id) || [])
return (
<article className="max-w-4xl mx-auto px-4 py-12">
{/* SEOメタ情報 */}
<title>{post.title} | My Blog</title>
<meta name="description" content={post.excerpt || ''} />
<meta property="og:title" content={post.title} />
<meta property="og:description" content={post.excerpt || ''} />
<meta property="og:type" content="article" />
<meta property="og:url" content={`https://myblog.com/blog/${slug}`} />
{/* 記事ヘッダー */}
<header className="mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
{post.title}
</h1>
<div className="flex items-center text-gray-600 text-sm mb-6">
<img
src={post.author.avatar_url || '/default-avatar.png'}
alt={post.author_name}
className="w-10 h-10 rounded-full mr-3"
/>
<div>
<p className="font-medium">{post.author_name}</p>
<time dateTime={post.published_at}>
{formatDate(post.published_at)}
</time>
</div>
</div>
{/* タグ */}
{post.tags && post.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{post.tags.map(tag => (
<a
key={tag.id}
href={`/blog?tag=${tag.slug}`}
className="px-3 py-1 bg-blue-100 text-blue-800 text-xs rounded-full hover:bg-blue-200 transition-colors"
>
{tag.name}
</a>
))}
</div>
)}
</header>
{/* 記事本文 */}
<div className="prose prose-lg max-w-none mb-12">
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</div>
{/* ソーシャル共有ボタン(アイランド) */}
<div className="border-t border-b py-8 mb-12">
<ShareButtons
title={post.title}
url={`https://myblog.com/blog/${slug}`}
text={post.excerpt}
/>
</div>
{/* 関連記事(アイランド) */}
<section className="mb-12">
<RelatedPosts
currentPostId={post.id}
initialData={relatedPosts}
/>
</section>
{/* コメント(アイランド) */}
<section>
<CommentSection postId={post.id} />
</section>
</article>
)
}
function BlogPostNotFound() {
return (
<div className="max-w-4xl mx-auto px-4 py-12 text-center">
<h1 className="text-3xl font-bold text-gray-900 mb-4">記事が見つかりません</h1>
<p className="text-gray-600 mb-8">
指定された記事は存在しないか、削除された可能性があります。
</p>
<a
href="/blog"
className="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
ブログ一覧に戻る
</a>
</div>
)
}
管理画面
// app/routes/admin/posts/index.tsx
import { PostsTable } from '../../../islands/admin/PostsTable'
import { requireAuth } from '../../../lib/middleware/auth'
export default function AdminPosts() {
// 管理者権限をチェック(ミドルウェア)
return (
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">投稿管理</h1>
<a
href="/admin/posts/new"
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
新規投稿
</a>
</div>
<PostsTable />
</div>
)
}
// app/routes/admin/posts/new.tsx
import { PostForm } from '../../../islands/admin/PostForm'
export default function NewPost() {
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">新規投稿</h1>
<p className="text-gray-600 mt-2">新しいブログ投稿を作成します</p>
</div>
<PostForm
mode="create"
onSuccess={(post) => {
window.location.href = `/admin/posts/${post.id}`
}}
/>
</div>
)
}
インタラクティブコンポーネント(アイランド)
投稿フォーム
// app/islands/admin/PostForm.tsx
import { useState } from 'react'
import { useCreatePost, useUpdatePost } from '../../lib/hooks/usePostMutations'
import type { Post, CreatePostRequest } from '../../lib/api/types'
interface PostFormProps {
mode: 'create' | 'edit'
initialData?: Partial<Post>
onSuccess?: (post: Post) => void
}
export function PostForm({ mode, initialData, onSuccess }: PostFormProps) {
const [formData, setFormData] = useState<CreatePostRequest>({
title: initialData?.title || '',
content: initialData?.content || '',
excerpt: initialData?.excerpt || '',
status: initialData?.status || 'draft',
tags: initialData?.tags?.map(t => t.name) || []
})
const [tagInput, setTagInput] = useState('')
const { createPost, loading: createLoading, error: createError } = useCreatePost()
const { updatePost, loading: updateLoading, error: updateError } = useUpdatePost()
const loading = createLoading || updateLoading
const error = createError || updateError
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
let post: Post
if (mode === 'create') {
post = await createPost(formData)
} else {
post = await updatePost(initialData!.id!, formData)
}
if (post && onSuccess) {
onSuccess(post)
}
} catch (err) {
// エラーハンドリングは各フックで行う
}
}
const addTag = () => {
if (tagInput.trim() && !formData.tags?.includes(tagInput.trim())) {
setFormData(prev => ({
...prev,
tags: [...(prev.tags || []), tagInput.trim()]
}))
setTagInput('')
}
}
const removeTag = (tagToRemove: string) => {
setFormData(prev => ({
...prev,
tags: prev.tags?.filter(tag => tag !== tagToRemove) || []
}))
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
タイトル
</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
本文
</label>
<textarea
value={formData.content}
onChange={(e) => setFormData(prev => ({ ...prev, content: e.target.value }))}
rows={20}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
抜粋
</label>
<textarea
value={formData.excerpt}
onChange={(e) => setFormData(prev => ({ ...prev, excerpt: e.target.value }))}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
タグ
</label>
<div className="flex gap-2 mb-2">
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="タグを入力"
/>
<button
type="button"
onClick={addTag}
className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
>
追加
</button>
</div>
<div className="flex flex-wrap gap-2">
{formData.tags?.map(tag => (
<span
key={tag}
className="inline-flex items-center px-3 py-1 bg-blue-100 text-blue-800 text-sm rounded-full"
>
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-2 text-blue-600 hover:text-blue-800"
>
×
</button>
</span>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
ステータス
</label>
<select
value={formData.status}
onChange={(e) => setFormData(prev => ({
...prev,
status: e.target.value as 'draft' | 'published'
}))}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="draft">下書き</option>
<option value="published">公開</option>
</select>
</div>
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-md">
<p className="text-red-600">{error}</p>
</div>
)}
<div className="flex gap-4">
<button
type="submit"
disabled={loading}
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
>
{loading ? '保存中...' : mode === 'create' ? '投稿を作成' : '投稿を更新'}
</button>
<button
type="button"
onClick={() => window.history.back()}
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50"
>
キャンセル
</button>
</div>
</form>
)
}
やってみよう!
完全なCRUDアプリケーションを構築してみましょう:
-
データベース設計
- 適切な正規化
- インデックス設定
- 制約の定義
-
API実装
- RESTful設計
- バリデーション
- エラーハンドリング
-
フロントエンド実装
- SSRによる高速表示
- インタラクティブな管理機能
- レスポンシブデザイン
-
セキュリティ
- 認証・認可
- XSS対策
- CSRF対策
ポイント
- フルスタック開発:フロントエンドからバックエンドまで一貫した開発体験
- 型安全性:データベースからUIまでエンドツーエンドの型安全性
- パフォーマンス:SSRとアイランドアーキテクチャによる最適化
- 保守性:適切な分離とモジュール化による可読性の向上
- スケーラビリティ:成長に対応できる柔軟なアーキテクチャ