はじめに
この記事では、Next.js 15とReact19およびHonoを活用してRPC機能を搭載したフルスタック開発をする方法について記載します。
特に、Next.jsのRoute HandlersをHonoで置き換えることで、HonoのRPC機能を活用し、APIルートをより堅牢かつ型安全にする実践的な手法を紹介します。これにより、APIエンドポイントのURLや戻り値の型を自動で取得できるため、効率的で信頼性の高いコードを書くことが可能になります。
それでは,開発のスピードと型安全性を両立する方法を学んでいきましょう!
アプリ概要
今回作成するアプリですが、以下の機能を持つSNS likeなアプリケーションを作っていこうと思います。
- ユーザー新規登録機能
- ログイン機能
- GitHub OAuth認証
- 投稿機能
- 投稿詳細取得
- 投稿編集機能
- 投稿削除機能
- いいね機能(Optimisitic UI更新)
また、今回のソースコードは以下になります。
技術スタック
- フロントエンドフレームワーク: Next.js
- APIフレームワーク: Hono
- DB: Neon DB
- ORM: Drizzle
- 認証: Auth.js
- hosting: Vercel
- スタイリング
- CSSフレームワーク: Tailwind CSS
- コンポーネントライブラリ: Justd
- Linter & Formmater: biome
- package manager: Bun
- form管理ライブラリ: react-hook-form・Conform
- バリデーションライブラリ: zod
実装
実装ですが、以下の手順で進めようと思います。
- Drizzle ORMのセットアップ
- DrizzleとNeon DBの連携
- schema作成
- Auth.jsによる認証実装
- SignUpページ作成
- SignInページ作成
- HonoによるAPI実装
- 投稿機能・編集・削除機能のServer Actions実装
- いいね機能のOptimistic UI更新
- Vercel deploy
Drizzle ORMのセットアップ
まず、Drizzle ORMのセットアップを行っていきます。
事前にNeon DBのプロジェクトを作成して、DBを作成し、postgresql://neondb
のような形式から始まるDATABASE_URL
を控えておいてください。
Neon DBと連携
以下に従い、ライブラリの導入等を行っていきます。
手順に従っていくと環境変数の設定を行う部分があると思いますが、環境変数についてはtype safeとなるよう扱いたいので、t3-oss/ts-env
を導入して管理していこうと思います。
以下のコマンドを実行して、必要なライブラリを導入しましょう。
bun add @t3-oss/env-nextjs zod
以下のようにすることで、環境変数をtype safeに扱うことができるようになります。
また、私の場合、あとからAuth.jsを使用するため、ドキュメント記載の環境変数名ではなく、AUTH_DRIZZLE_URL
としています。
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'
export const env = createEnv({
server: {
NODE_ENV: z.enum(['development', 'production']),
AUTH_DRIZZLE_URL: z.string().url(),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().url(),
},
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
AUTH_DRIZZLE_URL: process.env.AUTH_DRIZZLE_URL,
},
})
そして、drizzle
のDB連携の部分ですが、以下のようにします。
schemaについては次項で作成します。
import { env } from '@/env'
import * as schema from '@/libs/db/schema'
import { drizzle } from 'drizzle-orm/neon-http'
if (!env.AUTH_DRIZZLE_URL) {
throw new Error(`AUTH_DRIZZLE_URL is not defined ${env.AUTH_DRIZZLE_URL}`)
}
export const db = drizzle(env.AUTH_DRIZZLE_URL, { schema })
schema作成
以下のようにアプリ要件を満たすため、users
・accounts
・sessions
・posts
・likes
の5つのschemaを作成します。
なお、users
・accounts
・sessions
については、Auth.jsのドキュメントに記載があるものを少し編集したものを使用します。
※ verificationTokens
やauthenticators
は今回は使用しないので、定義からは除外しています
また、users
についてはbcrypt.jsによるhash化したpasswordと各種dateを持つようにしています
そして、それ以外のposts
やlikes
などはよくある構成になっていると思います。
各関数などについて詳しく知りたい方はdrizzleのドキュメントを参照してみてください。
import { relations } from 'drizzle-orm'
import {
index,
integer,
pgTable,
primaryKey,
text,
timestamp,
} from 'drizzle-orm/pg-core'
import type { AdapterAccountType } from 'next-auth/adapters'
export const users = pgTable('users', {
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text('name'),
email: text('email').unique(),
emailVerified: timestamp('emailVerified', { mode: 'date' }),
hashedPassword: text('hashedPassword'),
image: text('image'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => new Date()),
})
export const accounts = pgTable(
'account',
{
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
type: text('type').$type<AdapterAccountType>().notNull(),
provider: text('provider').notNull(),
providerAccountId: text('providerAccountId').notNull(),
refresh_token: text('refresh_token'),
access_token: text('access_token'),
expires_at: integer('expires_at'),
token_type: text('token_type'),
scope: text('scope'),
id_token: text('id_token'),
session_state: text('session_state'),
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId],
}),
}),
)
export const sessions = pgTable('sessions', {
sessionToken: text('session_token').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expires: timestamp('expires', { mode: 'date' }).notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => new Date()),
})
export const userRelations = relations(users, ({ many }) => ({
posts: many(posts),
likes: many(likes),
}))
export const posts = pgTable(
'posts',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
content: text('content').notNull(),
authorId: text('author_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'date' })
.defaultNow()
.notNull()
.$onUpdate(() => new Date()),
},
(posts) => ({
idIdx: index('posts_id_idx').on(posts.id),
authorIdx: index('posts_author_idx').on(posts.authorId),
createdAtIdx: index('posts_created_at_idx').on(posts.createdAt),
}),
)
export const postRelations = relations(posts, ({ one, many }) => ({
author: one(users, {
fields: [posts.authorId],
references: [users.id],
}),
likes: many(likes),
}))
export const likes = pgTable(
'likes',
{
postId: text('post_id')
.notNull()
.references(() => posts.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade', onUpdate: 'cascade' }),
createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'date' }).defaultNow().notNull(),
},
(like) => ({
postUserUnique: index('likes_post_user_unique').on(
like.postId,
like.userId,
),
postIdx: index('likes_post_idx').on(like.postId),
userIdx: index('likes_user_idx').on(like.userId),
createdAtIdx: index('likes_created_at_idx').on(like.createdAt),
}),
)
export const likeRelations = relations(likes, ({ one }) => ({
post: one(posts, {
fields: [likes.postId],
references: [posts.id],
}),
user: one(users, {
fields: [likes.userId],
references: [users.id],
}),
}))
ここまででschemaが作成できたので、Neon DBに反映していきましょう。
以下のコマンドを実行します。
bunx drizzle-kit generate
bunx drizzle-kit migrate
ここでエラー等なく成功したら、Neon DBのconsoleにアクセスしてTableを確認してみてください。
スキーマ通りにTableが作らているはずです。
Auth.jsによる認証実装
以下のNext.js用のinstallation
に従い、進めていきます。
ライブラリ導入
まず、必要なライブラリを導入していきます。
bun add next-auth@beta
Drizzleを使用するのでadapterも導入しましょう。
bun add @auth/drizzle-adapter
続いて環境変数を以下のコマンドで作成していきます。
bunx auth secret
Auth.jsのconfig作成
まずは、Auth.jsの認証処理の設定を行っていきます。
ドキュメントによるとプロジェクトのルートディレクトリにauth.ts
を作成するようになっていますが、src/
がある場合は、src/
に作成しても問題ないです。
import { config } from '@/libs/auth/config'
import NextAuth from 'next-auth'
export const { handlers, auth, signIn, signOut } = NextAuth(config)
そして、本題のconfigですが、以下のようにします。
DrizzleAdapter
の部分はドキュメントに従い、必要なテーブルスキーマのみを使用するように、オプションを設定します。
import { env } from '@/env'
import { db } from '@/libs/db/drizzle'
import { accounts, sessions, users } from '@/libs/db/schema'
import { DrizzleAdapter } from '@auth/drizzle-adapter'
import bcrypt from 'bcryptjs'
import { eq } from 'drizzle-orm'
import type { NextAuthConfig } from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import GitHub from 'next-auth/providers/github'
export const config = {
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
}),
callbacks: {
async signIn({ user, account }) {
if (account?.provider === 'github') {
return true
}
if (account?.provider !== 'credentials') {
return false
}
if (!user.id) {
return false
}
const existingUser = await db.query.users.findFirst({
where: eq(users.id, user.id),
})
if (!existingUser) {
return false
}
return true
},
session({ session, token }) {
if (token.sub) {
session.user.id = token.sub
}
return session
},
async jwt({ token }) {
if (!token.sub) {
return token
}
const existingUser = await db.query.users.findFirst({
where: eq(users.id, token.sub),
})
if (!existingUser) {
return token
}
token.name = existingUser.name
token.email = existingUser.email
token.image = existingUser.image
return token
},
},
providers: [
GitHub({
clientId: env.AUTH_GITHUB_ID,
clientSecret: env.AUTH_GITHUB_SECRET,
}),
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
authorize: async (credentials) => {
if (
!(
typeof credentials?.email === 'string' &&
typeof credentials?.password === 'string'
)
) {
throw new Error('Invalid credentials.')
}
const user = await db.query.users.findFirst({
where: eq(users.email, credentials.email),
})
if (!user?.hashedPassword) {
throw new Error('User has no password')
}
const isCorrectPassword = await bcrypt.compare(
credentials.password,
user.hashedPassword,
)
if (!isCorrectPassword) {
throw new Error('Incorrect password')
}
return {
id: user.id,
name: user.name,
email: user.email,
}
},
}),
],
pages: {
signIn: '/sign-in',
},
trustHost: true,
debug: process.env.NODE_ENV === 'development',
session: { strategy: 'jwt' },
secret: env.AUTH_SECRET,
} as const satisfies NextAuthConfig
細かい部分の説明ですが、ざっくりと以下のようになります。
-
callbacks
-
signIn
: サインイン時に実行されるコールバック(Providers
の認証後に行われる処理、GitHubならGitHub認証後、email・passwordならCredentials
のauthorize
の後に実行される) -
session
: tokenから取得したユーザーIDをsessionに設定 -
jwt
: tokenのカスタマイズ
-
-
providers
-
GitHub
: GitHub 設定ページにて作成したapplicationの情報を渡す -
Credetials
-
credentials
: 何を使って認証を行うかを設定(今回はemail・passwordを使用) -
authorize
: サインイン時に行われる処理(認証成功時にはユーザー情報を、失敗時にはエラーを返却)
-
-
-
pages
-
signIn
: カスタムログインページのパスを指定
-
-
trustHost
: ホストを信頼するかを決める(local build時に、この設定がないとエラーとなる) -
debug
: 開発環境でdebugログを出す -
session
:jwt
を採用 -
secret
: JWTの署名を行う秘密鍵
また、GitHubのOAuth機能を実現するため、以下の記事を参照し、GitHub上の設定を行ってください。
Googleについても行いたい方は、Google Cloud Consoleから各種IDやSECRETを取得していただければと思います。
configの作成ができたので、認証のAPI Route handlerを作成していきます。
catch-all-segments
によるAPI Routesとすることに注意してください。
import { handlers } from '@/auth'
export const { GET, POST } = handlers
最後にmiddleware
に以下を追加します。
これにより、セッションを維持できるようになります。
export { auth as middleware } from "@/auth"
SignUpページの作成
以下のようにCardコンポーネントを使用して、formを作成していきます。
import { Card } from '@/components/ui'
import { SignUpForm } from '@/features/auth/components/sign-up-form'
const SignUpPage = () => {
return (
<Card className="max-w-md w-full mx-auto">
<Card.Header>
<Card.Title>Sign Up</Card.Title>
<Card.Description>
Let's get you started by creating a new account.
</Card.Description>
</Card.Header>
<SignUpForm />
</Card>
)
}
export default SignUpPage
'use client'
import '@/utils/zod-error-map-utils'
import { Button, Card, Form, Loader, TextField } from '@/components/ui'
import { FieldError, Input, Label } from '@/components/ui/field'
import { signUp } from '@/features/auth/actions/sign-up'
import {
type SignUpSchema,
signUpSchema,
} from '@/features/auth/types/schemas/sign-up-schema'
import { useSafeForm } from '@/hooks/use-safe-form'
import {} from '@conform-to/zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { IconBrandGithub } from 'justd-icons'
import { signIn } from 'next-auth/react'
import Link from 'next/link'
import { useTransition } from 'react'
import { Controller } from 'react-hook-form'
import { toast } from 'sonner'
export const SignUpForm = () => {
const { control, setError, handleSubmit } = useSafeForm<SignUpSchema>({
resolver: zodResolver(signUpSchema),
defaultValues: {
name: '',
email: '',
password: '',
},
})
const [isPending, startTransition] = useTransition()
const onSubmit = (data: SignUpSchema) => {
startTransition(async () => {
const result = await signUp(data)
if (!result.isSuccess) {
result.error?.message === 'User already exists'
? setError('email', { message: 'User already exists' })
: toast.error(result.error?.message)
return
}
toast.success('User created successfully')
await signIn('credentials', {
email: data.email,
password: data.password,
callbackUrl: '/',
})
})
}
return (
<Form onSubmit={handleSubmit(onSubmit)}>
<Card.Content className="space-y-6">
<Controller
name="name"
control={control}
render={({
field: { name, value, onChange, onBlur, ref },
fieldState: { invalid, error },
}) => (
<TextField
label="Name"
name={name}
value={value}
onChange={onChange}
onBlur={onBlur}
isRequired={true}
validationBehavior="aria"
isInvalid={invalid}
errorMessage={error?.message}
>
<Label>Name</Label>
<Input ref={ref} />
</TextField>
)}
/>
<Controller
name="email"
control={control}
render={({
field: { name, value, onChange, onBlur, ref },
fieldState: { invalid, error },
}) => (
<TextField
label="Email"
type="email"
name={name}
value={value}
onChange={onChange}
onBlur={onBlur}
isRequired={true}
validationBehavior="aria"
isInvalid={invalid}
errorMessage={error?.message}
>
<Label>Email</Label>
<Input ref={ref} />
<FieldError>{error?.message}</FieldError>
</TextField>
)}
/>
<Controller
name="password"
control={control}
render={({
field: { name, value, onChange, onBlur, ref },
fieldState: { invalid, error },
}) => (
<TextField
label="Password"
type="password"
name={name}
value={value}
onChange={onChange}
onBlur={onBlur}
isRequired={true}
validationBehavior="aria"
isInvalid={invalid}
isRevealable={true}
errorMessage={error?.message}
>
<Label>Password</Label>
<Input ref={ref} />
<FieldError>{error?.message}</FieldError>
</TextField>
)}
/>
<div className="flex items-center gap-1">
Already have an account?{' '}
<Link href="/sign-in" className="text-blue-500 hover:underline">
Login
</Link>
</div>
</Card.Content>
<Card.Footer className="flex flex-col gap-2">
<Button type="submit" className="w-full" isDisabled={isPending}>
{isPending && <Loader />}
Sign Up
</Button>
<Button
type="button"
intent="secondary"
className="w-full"
isDisabled={isPending}
onPress={() => {
startTransition(async () => {
await signIn('github', { callbackUrl: '/' })
})
}}
>
<IconBrandGithub />
GitHub
</Button>
</Card.Footer>
</Form>
)
}
またsignUp時のserver actionsは以下のようにしています。
'use server'
import {
type SignUpSchema,
signUpSchema,
} from '@/features/auth/types/schemas/sign-up-schema'
import { db } from '@/libs/db/drizzle'
import { users } from '@/libs/db/schema'
import bcrypt from 'bcryptjs'
import { eq } from 'drizzle-orm'
export const signUp = async (data: SignUpSchema) => {
const submission = signUpSchema.safeParse(data)
if (!submission.success) {
return {
isSuccess: false,
error: { message: submission.error.message },
}
}
const existingUser = await db.query.users.findFirst({
where: eq(users.email, submission.data.email),
})
if (existingUser) {
return {
isSuccess: false,
error: { message: 'User already exists' },
}
}
const hashedPassword = await bcrypt.hash(submission.data.password, 12)
const [newUser] = await db
.insert(users)
.values({
name: submission.data.name,
email: submission.data.email,
hashedPassword,
image: '',
})
.returning()
return {
isSuccess: true,
data: { email: newUser.email, password: submission.data.password },
}
}
react-hook-form
を使用しているのは、Auth.js
のsignUp()
を使用するためです。
Conform
とuseActionState
で行いたかったのですが、どうもうまくできなかったので、react-hook-form
を使用するようにしました。
やっていることを説明すると、まず、server actionsでバリデーションと、ユーザーが既に存在しているかどうかを判定し、存在しなければ、パスワードをハッシュ化してusers テーブルにinsertしています。
次に、クライアント側で結果を受け取り、成功の場合、Toastの表示と、同時にサインインを行います。
サインインがない場合、userが登録されるだけで、認証されない状態になります。
そのため、登録と同時に認証が済むようにしています。
(この辺りは要件にもよるのですが、認証させずにログイン画面にredirectでも問題はないですが、SNS Likeのアプリなので、そのまま認証まで行います。)
SignInページの作成
SignInページもほとんど構成は同じです。
違いは、users テーブルにinsertするかしないか程度と思います。
import { Card } from '@/components/ui'
import { SignInForm } from '@/features/auth/components/sign-in-form'
const SignInPage = () => {
return (
<Card className="max-w-md w-full mx-auto">
<Card.Header>
<Card.Title>Log In</Card.Title>
<Card.Description>
Enter your email and password to log in.
</Card.Description>
</Card.Header>
<SignInForm />
</Card>
)
}
export default SignInPage
'use client'
import '@/utils/zod-error-map-utils'
import { Button, Card, Form, Loader, TextField } from '@/components/ui'
import { FieldError, Input, Label } from '@/components/ui/field'
import {
type SignInSchema,
signInSchema,
} from '@/features/auth/types/schemas/sing-in-schema'
import { useSafeForm } from '@/hooks/use-safe-form'
import {} from '@conform-to/zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { IconBrandGithub } from 'justd-icons'
import { signIn } from 'next-auth/react'
import Link from 'next/link'
import { useTransition } from 'react'
import { Controller } from 'react-hook-form'
import { toast } from 'sonner'
export const SignInForm = () => {
const { control, handleSubmit } = useSafeForm<SignInSchema>({
resolver: zodResolver(signInSchema),
defaultValues: {
email: '',
password: '',
},
})
const [isPending, startTransition] = useTransition()
const onSubmit = (data: SignInSchema) => {
startTransition(async () => {
await signIn('credentials', {
email: data.email,
password: data.password,
callbackUrl: '/',
})
toast.success('User logged in successfully')
})
}
return (
<Form onSubmit={handleSubmit(onSubmit)}>
<Card.Content className="space-y-6">
<Controller
name="email"
control={control}
render={({
field: { name, value, onChange, onBlur, ref },
fieldState: { invalid, error },
}) => (
<TextField
label="Email"
type="email"
name={name}
value={value}
onChange={onChange}
onBlur={onBlur}
isRequired={true}
validationBehavior="aria"
isInvalid={invalid}
errorMessage={error?.message}
>
<Label>Email</Label>
<Input ref={ref} />
<FieldError>{error?.message}</FieldError>
</TextField>
)}
/>
<Controller
name="password"
control={control}
render={({
field: { name, value, onChange, onBlur, ref },
fieldState: { invalid, error },
}) => (
<TextField
label="Password"
type="password"
name={name}
value={value}
onChange={onChange}
onBlur={onBlur}
isRequired={true}
validationBehavior="aria"
isInvalid={invalid}
isRevealable={true}
errorMessage={error?.message}
>
<Label>Password</Label>
<Input ref={ref} />
<FieldError>{error?.message}</FieldError>
</TextField>
)}
/>
<div className="flex items-center gap-1">
Don't have an account?{' '}
<Link href="/sign-up" className="text-blue-500 hover:underline">
Sign Up
</Link>
</div>
</Card.Content>
<Card.Footer className="flex flex-col gap-2">
<Button type="submit" className="w-full" isDisabled={isPending}>
{isPending && <Loader />}
Log In
</Button>
<Button
type="button"
intent="secondary"
className="w-full"
isDisabled={isPending}
onPress={() => {
startTransition(async () => {
await signIn('github', { callbackUrl: '/' })
})
}}
>
<IconBrandGithub />
GitHub
</Button>
</Card.Footer>
</Form>
)
}
ここまでで認証の処理は完了です。
続いて、APIを作成していきます。
HonoによるAPIの実装
以下を参照し、API RoutesをHonoで置き換えます。
HonoはREST APIを作成するだけで、RPC機能を実装できます。
そのため、API Routesを置き換えるだけで、型安全な開発が可能となります。
ということでセットアップしていきましょう。
HonoをAPI Routesに導入
まずは、Honoを導入します。
bun add hono
そして、catch-all-segmentsを使用して、API RoutesをHonoでハックしていきます。
import posts from '@/features/posts/server/route'
import { Hono } from 'hono'
import { handle } from 'hono/vercel'
export const runtime = 'edge'
const app = new Hono().basePath('/api')
const routes = app.route('/posts', posts)
export const GET = handle(app)
export const POST = handle(app)
export const PATCH = handle(app)
export const DELETE = handle(app)
export type AppType = typeof routes
これでセットアップは終わりました。
次に/posts
エンドポイントのAPIを作ります。
APIの作成
今回はGETのみ実装します。
(残りはserver actionsで実装するからです)
なので、投稿一覧の取得と、投稿詳細の取得のAPIを作って完了です。
import { db } from '@/libs/db/drizzle'
import { posts } from '@/libs/db/schema'
import { desc, eq } from 'drizzle-orm'
import { Hono } from 'hono'
const app = new Hono()
.get('/', async (c) => {
const postList = await db.query.posts.findMany({
with: {
author: true,
likes: true,
},
orderBy: [desc(posts.createdAt)],
})
return c.json({ posts: postList }, 200)
})
.get('/:postId', async (c) => {
const { postId } = c.req.param()
const post = await db.query.posts.findFirst({
with: {
author: true,
likes: true,
},
where: eq(posts.id, postId),
})
return c.json({ post }, 200)
})
export default app
fetchのヘルパー関数作成
以下の記事を参照し、fetcher関数を作成します。
デフォルトのfetchだと、jsonはanyになるので、以下のようにジェネリクスを渡して、型を付けていきます。
これで、型の恩恵を受けられるようになります。
import { redirect } from 'next/navigation'
type FetchArgs = Parameters<typeof fetch>
export async function fetcher<T>(url: FetchArgs[0], args: FetchArgs[1]) {
const response = await fetch(url, args)
if (!response.ok) {
redirect('/')
}
const json: T = await response.json()
return json
}
投稿一覧UIの作成
以下のようにAPIを呼び出していきます。
post投稿時にserver actionsでcache purgeできるようにするため、cacheにタグを付けてfetchします。
import { PostCard } from '@/features/posts/components/post-card'
import { fetcher } from '@/libs/fetcher'
import { client } from '@/libs/rpc'
import type { InferResponseType } from 'hono'
const url = client.api.posts.$url()
type ResType = InferResponseType<typeof client.api.posts.$get>
export const PostsCardContent = async () => {
const res = await fetcher<ResType>(url, {
next: { tags: ['posts'] },
})
return res.posts.map((post) => <PostCard post={post} key={post.id} />)
}
実際にres
部分をフォーカスすると型が取れているのがわかります。
そして投稿一覧ですが、画面上では、以下のようになっています。
投稿詳細UI作成
こちらも同じように、HonoのAPI clientから型を取得して、fetchしていきます。
import { PostCard } from '@/features/posts/components/post-card'
import { getSession } from '@/libs/auth/session'
import { fetcher } from '@/libs/fetcher'
import { client } from '@/libs/rpc'
import type { InferResponseType } from 'hono'
import { notFound } from 'next/navigation'
const fetchPost = async (postId: string) => {
const session = await getSession()
if (!session?.user) {
notFound()
}
type ResType = InferResponseType<(typeof client.api.posts)[':postId']['$get']>
const url = client.api.posts[':postId'].$url({
param: { postId },
})
const res = await fetcher<ResType>(url, {
next: { tags: [`posts/${postId}`] },
})
if (!res.post) {
notFound()
}
return res.post
}
type PostIdPageParams = {
params: Promise<{
postId: string
}>
}
const PostIdPage = async ({ params }: PostIdPageParams) => {
const postId = (await params).postId
const post = await fetchPost(postId)
return <PostCard post={post} />
}
export default PostIdPage
import { Avatar, Card } from '@/components/ui'
import { LikeButton } from '@/features/likes/components/like-button'
import { PostMenu } from '@/features/posts/components/post-menu'
import type { client } from '@/libs/rpc'
import { formatTimeAgo } from '@/utils/format-time'
import type { InferResponseType } from 'hono'
import { IconClock, IconForward, IconMessage } from 'justd-icons'
import Link from 'next/link'
type ResType = InferResponseType<(typeof client.api.posts)[':postId']['$get']>
type PostCardProps = {
post: NonNullable<ResType['post']>
}
export const PostCard = ({ post }: PostCardProps) => {
return (
<Card>
<Card.Header>
<div className="flex justify-between items-center">
<div className="flex w-full gap-4 items-center">
<Link href={'#'}>
<Avatar
src={post.author.image ? post.author.image : '/placeholder.png'}
alt="post avatar"
initials="PA"
/>
</Link>
<div className="flex gap-0 sm:gap-2 flex-col sm:flex-row">
<h5 className="text-base font-semibold">
{post.author.name ?? 'John Doe'}
</h5>
<p className="text-neutral-400">
@{post.author.id.substring(0, 12)}
</p>
</div>
</div>
<PostMenu post={post} />
</div>
</Card.Header>
<Card.Content className="space-y-4">
<Link href={`/${post.id}`}>
<p className="break-words text-lg">{post.content}</p>
</Link>
<div className="flex justify-between items-center w-full">
<div className="flex gap-6">
<LikeButton
postId={post.id}
initialLikes={post.likes.map((like) => like.userId)}
/>
<button type="button" className="flex items-center gap-2">
<IconMessage className="size-5" />
<span>{Math.floor(Math.random() * 100)}</span>
</button>
<button type="button">
<IconForward className="size-5" />
</button>
</div>
<div className="flex items-center gap-2">
<IconClock className="size-4" />
<span className="text-sm text-neutral-400">
{formatTimeAgo(new Date(post.createdAt))} ago
</span>
</div>
</div>
</Card.Content>
</Card>
)
}
画面上は以下のようになります。
以上でAPI部分の実装は完了です。
続いて、投稿機能・編集・削除機能について見ていきましょう。
投稿機能・編集・削除機能のServer Actions実装
それでは、順番に見ていきましょう。
なお、formバリデーションライブラリにはConfom
、server actionsの管理にはReact19で搭載されたuseActionState
を使用していきます。
以下の記事にかなり詳細に記載しておりますので、詳しくはそちらをご参照ください。
(今回は、そこまで詳しい説明は行いません)
投稿機能
フォームスキーマ定義
まずは、フォームのスキーマ定義ですが、以下のようにcontent
のみとします。
import { z } from 'zod'
export const postFormSchema = z.object({
content: z
// biome-ignore lint/style/useNamingConvention: This needs a snake_case name
.string({ required_error: 'Content is required' })
.min(1)
.max(300),
})
export type PostFormSchemaType = z.infer<typeof postFormSchema>
UI作成
次にform部分ですが、以下のようにします。
'use client'
import '@/utils/zod-error-map-utils'
import { Button, Form, Loader, TextField } from '@/components/ui'
import { addPost } from '@/features/posts/actions/add-post-action'
import { postFormSchema } from '@/features/posts/types/schema/post-form-schema'
import { getFormProps, getInputProps, useForm } from '@conform-to/react'
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
import { IconSend } from 'justd-icons'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useActionState } from 'react'
export const PostForm = () => {
const { data: session } = useSession()
const router = useRouter()
const [lastResult, action, isPending] = useActionState(addPost, null)
const [form, fields] = useForm({
constraint: getZodConstraint(postFormSchema),
lastResult,
onValidate({ formData }) {
return parseWithZod(formData, { schema: postFormSchema })
},
defaultValue: {
content: '',
},
})
return (
<Form
{...getFormProps(form)}
action={action}
className="w-full flex justify-between gap-4"
>
<div className="w-full flex flex-col gap-1">
<TextField
{...getInputProps(fields.content, { type: 'text' })}
placeholder="What's on your mind?"
isDisabled={isPending}
className="w-full"
errorMessage={''}
onFocus={() => {
if (!session) {
router.push('/sign-in')
}
}}
/>
<span id={fields.content.errorId} className="mt-1 text-sm text-red-500">
{fields.content.errors}
</span>
</div>
<Button
type="submit"
size="square-petite"
appearance="outline"
className={'h-10'}
isDisabled={isPending || fields.content.value === undefined}
>
{isPending ? <Loader /> : <IconSend />}
</Button>
</Form>
)
}
server actions
やっていることは、server 側でもバリデーションをかけて、成功の場合、postsテーブルへinsertし、投稿一覧で取得したcacheをpurgeして、新しいcacheにするということをしています。
このcache purgeの処理がないと、画面リフレッシュしない限り、古いcacheを参照するため、新しいデータがUIに表示されないので、忘れずに行いましょう。
'use server'
import { postFormSchema } from '@/features/posts/types/schema/post-form-schema'
import { getSession } from '@/libs/auth/session'
import { db } from '@/libs/db/drizzle'
import { posts } from '@/libs/db/schema'
import { parseWithZod } from '@conform-to/zod'
import { revalidateTag } from 'next/cache'
export const addPost = async (_: unknown, formData: FormData) => {
const submission = parseWithZod(formData, { schema: postFormSchema })
if (submission.status !== 'success') {
return submission.reply()
}
const session = await getSession()
if (!session?.user?.id) {
return submission.reply()
}
await db.insert(posts).values({
authorId: session.user.id,
content: submission.value.content,
})
revalidateTag('posts')
}
投稿編集
こちらも、新規投稿時と同様にcontent
のみなので、フォームスキーマは共通のものを使用します。
UI作成
こちらは、JSX部分は、Modalを使用するということ以外は、ほとんど新規投稿のformと同じ構造です。
ただ、編集時にはIDなどの識別子が必要なので、それだけ親から受取、formのaction属性で、formDataとしてappendする必要があります。
あるいは、form スキーマに定義して、TextFieldをhiddenにして渡す方法でも可能です。
そして、useEffect
では、server actionsの成功時にtoastが出るようにしています。
また、Modalを更新時に閉じないので、formの値を前のserver actionsの結果にする必要があるので、TextFieldのdefaultValue属性を設定しています。
Modalを閉じたい場合は、useEffect
にonClose
を記述するだけで対応可能です。
import { Button, Form, Loader, Modal, TextField } from '@/components/ui'
import { editPost } from '@/features/posts/actions/edit-post-action'
import { postFormSchema } from '@/features/posts/types/schema/post-form-schema'
import { getFormProps, getInputProps, useForm } from '@conform-to/react'
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
import { IconSend } from 'justd-icons'
import { useRouter } from 'next/navigation'
import { useActionState, useEffect } from 'react'
import { toast } from 'sonner'
type PostEditModalProps = {
postId?: string
content?: string
isOpen: boolean
onClose: () => void
}
export const PostEditModal = ({
postId,
content,
isOpen,
onClose,
}: PostEditModalProps) => {
const [lastResult, action, isPending] = useActionState(editPost, null)
const [form, fields] = useForm({
constraint: getZodConstraint(postFormSchema),
lastResult,
onValidate({ formData }) {
return parseWithZod(formData, { schema: postFormSchema })
},
defaultValue: {
content,
},
})
const router = useRouter()
useEffect(() => {
if (lastResult?.status === 'success') {
toast('Successfully updated on your post!', {
action: {
label: 'View',
onClick: () => {
router.push(`/${postId}`)
},
},
})
}
}, [lastResult, postId, router])
return (
<Modal isOpen={isOpen} onOpenChange={isPending ? undefined : onClose}>
<Modal.Content>
<Modal.Header>
<Modal.Title>Edit Post</Modal.Title>
</Modal.Header>
<Form
{...getFormProps(form)}
action={(formData) => {
if (!postId) {
return
}
formData.append('postId', postId)
action(formData)
}}
>
<TextField
{...getInputProps(fields.content, { type: 'text' })}
placeholder="What's on your mind?"
isDisabled={isPending}
defaultValue={
lastResult?.initialValue?.content.toString() ?? content
}
className="w-full"
errorMessage={''}
/>
<span
id={fields.content.errorId}
className="mt-1 text-sm text-red-500"
>
{fields.content.errors}
</span>
<Modal.Footer>
<Modal.Close isDisabled={isPending}>Cancel</Modal.Close>
<Button
type="submit"
size="square-petite"
appearance="outline"
className={'h-10'}
isDisabled={isPending || fields.content.value === undefined}
>
{isPending ? <Loader /> : <IconSend />}
</Button>
</Modal.Footer>
</Form>
</Modal.Content>
</Modal>
)
}
画面上では、以下のように表示されます。
server actions
こちらも新規投稿とほとんど同じで、該当のデータにupdate
を行うようにするだけで実装できます。
submissionを返却する理由は、useEffectの処理や、defaultValue属性の値を設定するためです。
'use server'
import { postFormSchema } from '@/features/posts/types/schema/post-form-schema'
import { getSession } from '@/libs/auth/session'
import { db } from '@/libs/db/drizzle'
import { posts } from '@/libs/db/schema'
import { parseWithZod } from '@conform-to/zod'
import { eq } from 'drizzle-orm'
import { revalidateTag } from 'next/cache'
export const editPost = async (_: unknown, formData: FormData) => {
const submission = parseWithZod(formData, { schema: postFormSchema })
if (submission.status !== 'success') {
return submission.reply()
}
const session = await getSession()
if (!session?.user?.id) {
return submission.reply()
}
await db
.update(posts)
.set({
content: submission.value.content,
updatedAt: new Date(),
})
.where(eq(posts.id, formData.get('postId') as string))
revalidateTag('posts')
return submission.reply()
}
投稿削除
最後に、削除機能についてです。
UI作成
UIは以下のようにMenuとして作成します。
実際の削除処理はhandleDelete
で行っています。
'use client'
import { Menu } from '@/components/ui'
import { deletePost } from '@/features/posts/actions/delete-post-action'
import { PostEditModal } from '@/features/posts/components/post-edit-modal'
import type { client } from '@/libs/rpc'
import type { InferResponseType } from 'hono'
import {
IconDotsHorizontal,
IconPencilBox,
IconResizeOutIn,
IconTrashEmpty,
} from 'justd-icons'
import { useSession } from 'next-auth/react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useState, useTransition } from 'react'
import { toast } from 'sonner'
type ResType = InferResponseType<(typeof client.api.posts)[':postId']['$get']>
type PostMenuProps = {
post: NonNullable<ResType['post']>
}
export const PostMenu = ({ post }: PostMenuProps) => {
const { data: session } = useSession()
const pathname = usePathname()
const [isPending, startTransition] = useTransition()
const [isOpen, setIsOpen] = useState(false)
const handleDelete = () => {
startTransition(async () => {
const result = await deletePost(post.id)
if (result.isSuccess) {
toast.success('Post deleted successfully')
} else {
toast.error('Failed to delete post')
}
})
}
return (
<>
<Menu>
{pathname === '/' || post.authorId === session?.user?.id ? (
<Menu.Trigger>
<IconDotsHorizontal />
</Menu.Trigger>
) : null}
<Menu.Content className="min-w-48" placement="bottom">
{pathname === '/' && (
<Menu.Item isDisabled={isPending}>
<Link href={`/${post.id}`} className="flex items-center gap-1">
<IconResizeOutIn />
View
</Link>
</Menu.Item>
)}
{post.authorId === session?.user?.id && (
<>
<Menu.Item
isDisabled={isPending}
onAction={() => setIsOpen(true)}
>
<IconPencilBox />
Edit
</Menu.Item>
<Menu.Separator />
<Menu.Item
isDanger={true}
onAction={handleDelete}
isDisabled={isPending}
>
<IconTrashEmpty />
Delete
</Menu.Item>
</>
)}
</Menu.Content>
</Menu>
<PostEditModal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
postId={post?.id}
content={post?.content}
/>
</>
)
}
画面上では、以下のようになります。
server actions
削除処理はformを介さないのでserver actionsの結果を返却するようにして、toastを出し分けられるようにします。
テーブルの処理としては、SQLのdeleteを行うようにするだけです。
'use server'
import { getSession } from '@/libs/auth/session'
import { db } from '@/libs/db/drizzle'
import { posts } from '@/libs/db/schema'
import { eq } from 'drizzle-orm'
import { revalidateTag } from 'next/cache'
export const deletePost = async (postId: string) => {
const session = await getSession()
if (!session?.user) {
return { isSuccess: false, error: { message: 'Unauthorized' } }
}
try {
await db.delete(posts).where(eq(posts.id, postId))
revalidateTag('posts')
return { isSuccess: true }
} catch (err) {
return { isSuccess: false, error: { message: 'Failed to delete post' } }
}
}
ここまでで一通りCRUDの実装が完了しました。
最後にReact19で搭載された、楽観的UI更新を簡単にするuseOptimisticをいいね機能で使ってみたいと思います。
X(旧Twitter)などSNSのいいね機能などは基本楽観的UI更新をしているので、その挙動を作ってみようと思います。
いいね機能 Optimistic UI
まずは、コンポーネント部分からです。
UI作成
以下のPostCard
で使用しているLikeButton
で楽観的UI更新を行っていきます。
import { Avatar, Card } from '@/components/ui'
import { LikeButton } from '@/features/likes/components/like-button'
import { PostMenu } from '@/features/posts/components/post-menu'
import type { client } from '@/libs/rpc'
import { formatTimeAgo } from '@/utils/format-time'
import type { InferResponseType } from 'hono'
import { IconClock, IconForward, IconMessage } from 'justd-icons'
import Link from 'next/link'
type ResType = InferResponseType<(typeof client.api.posts)[':postId']['$get']>
type PostCardProps = {
post: NonNullable<ResType['post']>
}
export const PostCard = ({ post }: PostCardProps) => {
return (
<Card>
<Card.Header>
<div className="flex justify-between items-center">
<div className="flex w-full gap-4 items-center">
<Link href={'#'}>
<Avatar
src={post.author.image ? post.author.image : '/placeholder.png'}
alt="post avatar"
initials="PA"
/>
</Link>
<div className="flex gap-0 sm:gap-2 flex-col sm:flex-row">
<h5 className="text-base font-semibold">
{post.author.name ?? 'John Doe'}
</h5>
<p className="text-neutral-400">
@{post.author.id.substring(0, 12)}
</p>
</div>
</div>
<PostMenu post={post} />
</div>
</Card.Header>
<Card.Content className="space-y-4">
<Link href={`/${post.id}`}>
<p className="break-words text-lg">{post.content}</p>
</Link>
<div className="flex justify-between items-center w-full">
<div className="flex gap-6">
<LikeButton
postId={post.id}
initialLikes={post.likes.map((like) => like.userId)}
/>
<button type="button" className="flex items-center gap-2">
<IconMessage className="size-5" />
<span>{Math.floor(Math.random() * 100)}</span>
</button>
<button type="button">
<IconForward className="size-5" />
</button>
</div>
<div className="flex items-center gap-2">
<IconClock className="size-4" />
<span className="text-sm text-neutral-400">
{formatTimeAgo(new Date(post.createdAt))} ago
</span>
</div>
</div>
</Card.Content>
</Card>
)
}
そして、実際にいいね機能を実装するLikeButton
は以下のようにしています。
'use client'
import { Form, TextField } from '@/components/ui'
import { likeAction } from '@/features/likes/actions/like-action'
import { likeFormSchema } from '@/features/likes/types/schema/like-form-schema'
import { getFormProps, getInputProps, useForm } from '@conform-to/react'
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
import { IconHeart, IconHeartFill } from 'justd-icons'
import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import { useActionState, useOptimistic } from 'react'
type LikeButtonProps = {
postId: string
initialLikes: string[]
}
export const LikeButton = ({ postId, initialLikes }: LikeButtonProps) => {
const { data: session } = useSession()
const router = useRouter()
const [optimisticLike, setOptimisticLike] = useOptimistic<
{ likeCount: number; isLiked: boolean },
void
>(
{
likeCount: initialLikes.length || 0,
isLiked: session?.user?.id
? initialLikes?.includes(session.user.id)
: false,
},
(currentState) => ({
likeCount: currentState.isLiked
? currentState.likeCount - 1
: currentState.likeCount + 1,
isLiked: !currentState.isLiked,
}),
)
const [lastResult, action] = useActionState(likeAction, null)
const [form, fields] = useForm({
constraint: getZodConstraint(likeFormSchema),
lastResult,
onValidate({ formData }) {
return parseWithZod(formData, { schema: likeFormSchema })
},
onSubmit() {
if (!session) {
router.push('/sign-in')
return
}
setOptimisticLike()
},
defaultValue: {
postId,
},
})
return (
<Form
{...getFormProps(form)}
action={action}
className="flex items-center gap-1"
>
<TextField {...getInputProps(fields.postId, { type: 'hidden' })} />
<button type="submit" className="flex items-center gap-2">
{optimisticLike.isLiked ? (
<IconHeartFill className="size-5 text-red-500" />
) : (
<IconHeart className="size-5" />
)}
</button>
<span className={optimisticLike.isLiked ? 'text-red-500' : ''}>
{optimisticLike.likeCount}
</span>
</Form>
)
}
少し整理してみましょう。
まず、PostCard
の該当箇所を切り出すのと、postの型を見てみましょう。
そして、LikeButton
のpropsの型は以下となっています。
type LikeButtonProps = {
postId: string
initialLikes: string[]
}
つまり、propsには、どの投稿なのかを判別する識別子と、postが持っているlikes配列をuserIdの配列としてpropsを渡していることになります。
例えば、以下のような値が渡ります。
- postId: '670867bf-00b7-4c7a-8ac3-eedb9802f752'
- initialLikes: ['a04f6e1f-e42b-4ce8-8f29-492fd460d186', '4c509be0-be67-4864-b836-0a5ef1087b91',・・・]
<LikeButton
postId={post.id}
initialLikes={post.likes.map((like) => like.userId)}
/>
いいね楽観的UI更新のロジック
ここでLikeButton
に話を戻します。
まず、useOptimistic
ですが、UI上でどの要素を更新するのかという観点で定義が決まります。
今回のいいね機能の場合、いいね数とログインユーザーがいいねしたかどうかというbool値がUIの更新には必要になります。
そのため、第一引数には、初期値として、いいね数(likes配列が持つuserIdの数)とログインユーザーのidがuserIdの配列に含まれているかどうかという値を設定します。
そして、第二引数には、更新後の状態を返却するよう処理を記載します。
この第二引数についてはuseActionState
も同じことが言え、useReducer
のように今のstateを受けとって、新しいstateを返却するというルールを理解するとわかりやすいと思います。
const [optimisticLike, setOptimisticLike] = useOptimistic<
{ likeCount: number; isLiked: boolean },
void
>(
{
likeCount: initialLikes.length || 0,
isLiked: session?.user?.id
? initialLikes?.includes(session.user.id)
: false,
},
(currentState) => ({
likeCount: currentState.isLiked
? currentState.likeCount - 1
: currentState.likeCount + 1,
isLiked: !currentState.isLiked,
}),
)
あとは上記をJSXに適用するだけで、クライアント側の楽観的UI更新の処理は実装完了です。
ログインユーザーがいいねしていたら、IconHeartFill
が表示され、いいねしていなければIconHeart
が表示されます。
そして、いいね数も表示します。
<Form
{...getFormProps(form)}
action={action}
className="flex items-center gap-1"
>
<TextField {...getInputProps(fields.postId, { type: 'hidden' })} />
<button type="submit" className="flex items-center gap-2">
{optimisticLike.isLiked ? (
<IconHeartFill className="size-5 text-red-500" />
) : (
<IconHeart className="size-5" />
)}
</button>
<span className={optimisticLike.isLiked ? 'text-red-500' : ''}>
{optimisticLike.likeCount}
</span>
</Form>
画面上は以下のようになります。
server action
そして、server actionですが、やっていることはシンプルで、当該postにおいて、ログインユーザーが既にいいねを押していたら、exsitedLike
が取得できるため、その場合は、いいねを削除し、そうでなければ新規登録します。
そしてcacheに更新をかけるということをしています。
'use server'
import { likeFormSchema } from '@/features/likes/types/schema/like-form-schema'
import { getSession } from '@/libs/auth/session'
import { db } from '@/libs/db/drizzle'
import { likes } from '@/libs/db/schema'
import { parseWithZod } from '@conform-to/zod'
import { and, eq } from 'drizzle-orm'
import { revalidateTag } from 'next/cache'
export const likeAction = async (_: unknown, formData: FormData) => {
const session = await getSession()
if (!session?.user?.id) {
throw new Error('Unauthorized')
}
const submission = parseWithZod(formData, {
schema: likeFormSchema,
})
if (submission.status !== 'success') {
return submission.reply()
}
const userId = session.user.id
const existedLike = await db.query.likes.findFirst({
where: and(
eq(likes.postId, submission.value.postId),
eq(likes.userId, userId),
),
})
if (existedLike) {
await db
.delete(likes)
.where(
and(
eq(likes.postId, existedLike.postId),
eq(likes.userId, existedLike.userId),
),
)
} else {
await db.insert(likes).values({
postId: submission.value.postId,
userId,
})
}
revalidateTag(`likes/${submission.value.postId}`)
}
動作確認してみましょう。
しっかり高速に更新できているようです。
注意点として、DBに保存されていいない状態(server actonsが完了していない状態)で画面リフレッシュをすると当然データはDBにないので、UIのみ更新されるという状態になります。
以下のgifの通り、少し待って更新するとサーバー側との同期が取れているので、しっかりと更新後のデータとなっていることがわかると思います。
話はそれますが、Neon DBのリージョンは最も近いもので、シンガポールなので、日本リージョンが使用できるPlanet Scaleなどだと、サーバーとの同期もより高速になります。
Neon DBも日本リージョンを出すことを検討しているようですが、まだのようです。
また、Planet Scaleは2024/2に無料プランがなくなり、値上げ(改悪)となったので、悲報以外の何物でもありません。
Vercel deploy
最後にVercel へ deployしてみてください。
前提としてGitHubでソース管理していることが条件です。
以下からVercelにログインし、プロジェクトをhostingしていきます。
環境変数を設定するだけでdeploy作業は完了です。
最後に、GitHubのOAuth実装時に設定したGitHubのapplicationsのコールバックURLなどをVercelのデプロイ先のURLに変更しましょう。
以上で、実装は完了です。
おわりに
いかがでしたでしょうか?
HonoのRPCがかなり強力かつ、React19が便利すぎるということが伝わったのではないでしょうか。
React19では特にuseActionState
は高頻度で使うので、これはフロントエンドやっていくうえでは必修科目です。なので、今のうちからしっかりキャッチアップしておきましょう!
参考文献