【図解解説】Next.js,Hono,Drizzle,Zod,ClerkでTwitterクローンを開発するチュートリアル【JStack/TypeScript/Neon/Cloudinary】

![アートボード 1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/3ba87ef2-cbc1-44e3-87b2-cd1e6d2b5431.png)

# はじめに

こんにちは、Watanabe Jin ([@Sicut_study](https://x.com/Sicut_study))です。

**突然ですが、皆さんはT3 Stackという言葉をご存知でしょうか？**

<font color="red">**T3 Stack**</font>とはTheo氏によって2021年に提唱されたWebアプリケーション開発のための技術スタックです。

||
|---|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/deb242a5-a3f5-4d1f-aa26-d289fbfc7a3b.png)|
|<font color="gray">作者をYoutubeで一度はみたことあるのではないいでしょうか？|

T3 Stackは以下のような思想があるアプリケーションです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/d361d1ec-c5cf-431b-80e3-2bc7f80d91e6.png)

「簡素さ」「モジュール性」「フルスタック安全」を実現できる技術スタックを集めた総称をT3 Stackと呼びます。

- Next.js
- TypeScript
- trpc
- NextAuth.js
- Prisma
- TailwindCSS

で構成されています。

2021年に生まれて多く利用されてきた**モダンなスキルスタック**と呼ばれていた構成でしたが現代いくつかの不満が生まれてきました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/52720a2c-e0e4-4977-9a8c-91195ed69652.png)

そんな不満をもったJosh氏が作ったのが<font color="red">**JStack**</font>でした。

https://jstack.app/

今回は「JStack」を使ってX(旧:Twitter)のクローンアプリを開発していきます。

![名称未設定のデザイン (3).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/937f3fa3-4df1-4781-8c64-2c348d0dbb94.gif)


JStackを使うことでエンドツーエンドの型安全を体験しながら、Clerkを組み合わせてよりT3 Stackに近い開発ができるようにチュートリアルを作成しました。

**このチュートリアルをやることでモダンな技術スタックをまとめて手を動かして学ぶことできます!**

# 動画教材も用意しています
こちらの教材にはより詳しく解説した動画もセットでご用意しています。
テキスト教材でわからない細かい箇所があれば動画も活用ください

<iframe width="560" height="315" src="https://www.youtube.com/embed/zqX44IhgKd8?si=hjjC_8OAaZzk7W8m" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

https://github.com/jinwatanabe/jstack-twitter-clone

# 対象者
- Next.jsを学んでみたい人
- Reactを少し学んだことがある人
- モダンなスキルを全体的に学びたい人
- ログイン認証を実装してみたい人
- アプリ開発を通して学びたい人

**Reactの基本的な仕組みを理解している人であれば2時間程度で学習することが可能です**

# What is JStack?

JStackはJosh氏が開発したTypeScriptとNext.jsをベースにしたフルスタック開発ツールキットです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/3445c5ce-800b-4406-9f4c-4e56fac2844e.png)

JStackは、T3 Stackの制限を解消し、開発者体験とアプリケーションパフォーマンスを向上させるために作成されました。

スキルスタックは以下で構成されています。

1. **TypeScript**
TypeScriptを利用することでフロントエンドからバックエンドまでの一貫した型安全性を提供し、開発段階でのエラーを減らします。
　
2. **Next.js**
Reactをベースにしたフルスタックフレームワークです。高性能なWebアプリケーションを構築できます。
　
3. **Hono**
軽量でポータブルなバックエンドフレームワークです。Honoを使用してWeb標準のレスポンスをネイティブにサポートし、JSON以外のレスポンス形式も扱えるます。
　
4. **Drizzle**
TypeScriptで書かれた軽量で柔軟なORMです。
Drizzleを使用してデータベース操作を型安全に行い、SQLのようなクエリAPIも提供します。
　
5. **Zod**
ランタイムでのデータ検証を提供するライブラリです。
Zodを使用してデータの整合性を保ち、TypeScriptとの連携でさらに安全なデータ処理を実現します。

  
これらの技術を組み合わせることで、JStackは開発者に高速で軽量な、かつエンドツーエンドで型安全な開発環境を提供します。
  
    
T3 Stackの作者であるTheo氏も絶賛しております。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/a1802990-88f8-4f4e-9d7d-a4a02c21b921.png)


# 今回利用する技術

今回はJStackの他にも以下の技術を利用することでモダンなアプリケーションを構築していきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/ca0a3e62-d63a-42d5-be8c-6d01a4c0725e.png)

認証で人気のサービスであるClerkやDBにはNeonを採用してTwitterクローンアプリを作ります。Clerk・Neonはともに無料枠があり簡単に利用できるため柔軟に開発できることで海外では人気となっています。
また、JStackの中ではReact Queryなどのライブラリも利用しています。

その他にも画像保存には**Cloudinary**というサービスも利用しています。

# 1. 環境構築

JStackは簡単に環境構築ができます。Node.jsがあるかを確認します。

```
$ node -v
v20.8.0
```

もしNode.jsがない場合はインストールをして下さい。

https://nodejs.org/ja

JStackをスタートガイドどおりにプロジェクト作成しましょう。

https://jstack.app/docs/getting-started/first-steps

```
$ npx create-jstack-app@latest

┌   jStack CLI 
│
◇  What will your project be called?
│  twitter-clone-app
│
◇  Which database ORM would you like to use?
│  Drizzle ORM
│
◇  Which Postgres provider would you like to use?
│  Neon
│
◇  Should we run 'npm install' for you?
│  Yes
```

このタイミングで「Drizzle ORM」と「Neon」を選択しました。


```
$ cd twitter-clone-app
```

VSCodeでプロジェクトを開きます。
そしてターミナルでサーバーを起動するコマンドを実行しましょう。

```
$ npm run dev
```

[http://localhost:3000](http://localhost:3000)にアクセスしましょう

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/74624a78-3974-4b35-8111-5856a29408f0.png)

この画面が表示されれば大丈夫です。
「Create Post」とありますが、ここはDBを接続しないとエラーになるのでこの後設定します。

次に**Shadcn/ui**を導入します。
React19になってShadcn/uiのインストールが少し変わったので気をつけてください。
shdcnはTailwindCSSを利用していますが、JStackにはデフォルトで入っているのでshadcnからインストールを始めれば大丈夫です。

https://ui.shadcn.com/docs/installation/next

```
$ npx shadcn@latest init
shadcn@2.4.0-canary.12
Ok to proceed? (y) y
✔ Preflight checks.
✔ Verifying framework. Found Next.js.
✔ Validating Tailwind CSS config. Found v4.
✔ Validating import alias.
✔ Which color would you like to use as the base color? › Neutral
✔ Writing components.json.
✔ Checking registry.
✔ Updating src/app/globals.css
  Installing dependencies.

It looks like you are using React 19. 
Some packages may fail to install due to peer dependency issues in npm (see https://ui.shadcn.com/react-19).

✔ How would you like to proceed? › Use --legacy-peer-deps

$ npx shadcn@latest add button
✔ How would you like to proceed? › Use --legacy-peer-deps
```

React19にインストールする場合は`Use --legacy-peer-deps`を選択する必要があります。

「Create Post」のボタンをShadcnのボタンコンポーネントに変えてみましょう

```tsx:src/app/components/post.tsx
"use client"

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { client } from "@/lib/client"
import { Button } from "@/components/ui/button" // 追加

export const RecentPost = () => {
  const [name, setName] = useState<string>("")
  const queryClient = useQueryClient()

  const { data: recentPost, isPending: isLoadingPosts } = useQuery({
    queryKey: ["get-recent-post"],
    queryFn: async () => {
      const res = await client.post.recent.$get()
      return await res.json()
    },
  })

  const { mutate: createPost, isPending } = useMutation({
    mutationFn: async ({ name }: { name: string }) => {
      const res = await client.post.create.$post({ name })
      return await res.json()
    },
    onSuccess: async () => {
      await queryClient.invalidateQueries({ queryKey: ["get-recent-post"] })
      setName("")
    },
  })

  return (
    <div className="w-full max-w-sm backdrop-blur-lg bg-black/15 px-8 py-6 rounded-md text-zinc-100/75 space-y-2">
      {isLoadingPosts ? (
        <p className="text-[#ececf399] text-base/6">
          Loading posts...
        </p>
      ) : recentPost ? (
        <p className="text-[#ececf399] text-base/6">
          Your recent post: "{recentPost.name}"
        </p>
      ) : (
        <p className="text-[#ececf399] text-base/6">
          You have no posts yet.
        </p>
      )}
      <form
        onSubmit={(e) => {
          e.preventDefault()
          createPost({ name })
        }}
        onKeyDown={(e) => {
          if (e.key === "Enter" && !e.shiftKey) {
            e.preventDefault()
            createPost({ name })
          }
        }}
        className="flex flex-col gap-4"
      >
        <input
          type="text"
          placeholder="Enter a title..."
          value={name}
          onChange={(e) => setName(e.target.value)}
          className="w-full text-base/6 rounded-md bg-black/50 hover:bg-black/75 focus-visible:outline-none ring-2 ring-transparent  hover:ring-zinc-800 focus:ring-zinc-800 focus:bg-black/75 transition h-12 px-4 py-2 text-zinc-100"
        />
        {/* 修正 */}
        <Button
          disabled={isPending}
          type="submit"
          variant="destructive"
        >
          {isPending ? "Creating..." : "Create Post"}
        </Button>
      </form>
    </div>
  )
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/434a8712-c5e6-4400-b125-83f6715651c6.png)

サーバーを再起動してアクセスをすると、ボタンが正しく表示されているので導入がうまくいっていることが確認できました。

# 2.DBの連携をする

ここからは「Neon」と「Drizzle」を使ってDBの初期設定をしていきます。
いまアプリにあるポストの作成と最新ポストの表示を目指します。

まずはNeonでプロジェクトを作成します。アカウントがない方は作成してログインしてください。

https://neon.tech/?gad_source=1&gclid=CjwKCAjwvr--BhB5EiwAd5YbXnPRBf2oQhVfL65q-J9IMsm5klhhIXT_EcHEb4UFTuFtHWc_wYFf_RoCyRwQAvD_BwE

「New Project」をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/42b4b242-9f4b-4162-bf96-f1ed9c312a59.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/ec16a6d0-5e31-46e5-9c85-e9263a75112a.png)

- Project name : twitter-clone-app
- Database name : mydb

これらを入力して「Create」をクリック
この時点ではテーブルやスキーマなどはないので、コードで書いて反映をさせます。
ここで利用できるのがDrizzle ORMです。

このプロジェクトでは`src/server/db/schema.ts`にテーブル定義のサンプルが書いてあります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/737f1e75-8431-4403-9a75-2e4395528179.png)

これをDBに反映させましょう。

まずは`.env`にDB_URLを追加します。
Neonを開いて「Connect」を押して「Connect String」をコピーして貼り付けます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/5e7f2b8f-789f-4465-bb8c-3213f0a617d7.png)


```.env
DATABASE_URL=あなたのURL
```

スキーマとテーブルをDBに反映させるコマンドは事前に用意されています。

```json:package.json
{
  "name": "twitter-clone-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "build": "next build",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:push": "drizzle-kit push",
    "db:studio": "drizzle-kit studio",
    "dev": "next dev",
    "lint": "next lint",
    "start": "next start"
  },
```

`npm run db:generate`とすることでテーブルの型情報が開発で使えるようになります。
`npm run db:push`とすることでNeonのDBを更新することができます。

```
$ npm run db:push // Neonを更新

[✓] Pulling schema from database...
[✓] Changes applied
```

実際にNeonを開いて、左メニューから「Tables」を選ぶとテーブルが自動で作成されたことがわかります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/ad0103fa-35b6-4f90-b5bb-1469e20df1af.png)

これでDBの連携はできたので実際にアプリケーションのフォームに入力して「Create Post」をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/e6a94626-ca3d-4d5b-88b4-023feb87af0a.png)

入力したものが表示されています。DBをみてもデータが追加されていることがわかります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/f52f650c-5f31-48d7-9ec6-8acfdff5b36d.png)

# 3. ログインできるようにしよう

続いてClerkを使ってログインできるようにします。
アカウントがない人は作成をしてから次に進んでください。

https://clerk.com/

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/022da2d6-6fcd-473c-9c8c-d57f7ea81e6f.png)

「Create appplication」をクリック (横にあるプロジェクトは無視してください)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/ad59d2c2-43a1-4ad3-81f4-d53b1bf47b65.png)

「Application name」に**twitter-clone-app**と入力して「Create application」をクリック

ここからは初期設定を手順通りに行います。

```
$ npm install @clerk/nextjs
```

手順2のAPIキーをすべてコピーして`.env`に貼り付けます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/7cad525c-596e-4e9b-ab24-cca4126ea934.png)

```.env
DATABASE_URL=あなたのDATABASE_URL
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=あなたのNEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
CLERK_SECRET_KEY=あなたのCLERK_SECRET_KEY
```

次にmiddleware.tsを作成します。

```
$ touch src/middleware.ts
```

```ts:middleware.ts
import { clerkMiddleware } from "@clerk/nextjs/server";

export default clerkMiddleware();

export const config = {
  matcher: [
    // Skip Next.js internals and all static files, unless found in search params
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    // Always run for API routes
    '/(api|trpc)(.*)',
  ],
};
```

middleware.tsはリクエストがルートハンドラーやページによって処理される前に実行されるコードで、リクエストの検査や変更、レスポンスの変更などを行うことができます。今回はそれぞれのページにアクセスするごとに認証が必要かを判定する役割で利用します。

次に今回作成するアプリでClerkを利用できるようにProviderを設定します。

```ts:src/app/layout.ts
import type { Metadata } from "next";
import { ClerkProvider, SignedIn, SignedOut } from "@clerk/nextjs";
import "./globals.css";

export const metadata: Metadata = {
  title: "JStack Twitter Clone",
  description: "Twitter clone created using JStack",
  icons: [{ rel: "icon", url: "/favicon.ico" }],
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body className="antialiased">
          <div className="flex min-h-screen">
            <main className="flex-1 transition-all duration-300">
              <Providers>{children}</Providers>
            </main>
          </div>
        </body>
      </html>
    </ClerkProvider>
  );
}
```

全ページ共通のレイアウトでClerkProviderを使ってchildren(実際のページ固有の内容)を囲うことで、認証を全てのページで利用することができます。

Providerを使うことで簡単に認証しているかどうかや、認証しているユーザーの情報を取得できるようになります。

```ts
    <ClerkProvider>
      (省略
    </ClerkProvider>
```

次にログインが必要な画面を用意します。今回は`/`をTwitterのタイムラインとするので認証していないとみれない(認証してない場合はログイン画面にリダイレクト)ように設定します。

```ts:middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; // 追加

const isProtectedRoute = createRouteMatcher(["/"]); // 追加

// 追加
export default clerkMiddleware(async (auth, req) => {
  if (isProtectedRoute(req)) {
    await auth.protect();
  }
});

export const config = {
  matcher: [
    // Skip Next.js internals and all static files, unless found in search params
    "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
    // Always run for API routes
    "/(api|trpc)(.*)",
  ],
};
```

`createRouteMatcher`で認証したいページを設定します。

```ts
const isProtectedRoute = createRouteMatcher(["/"]);
```

`/`に対応するページを作成します。

```tsx:src/app/page.tsx
import React from "react";

function Home() {
  return <div>Home</div>;
}

export default Home;
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/ddb02b89-9b3e-4d66-a203-c863f57f3196.png)



`clerkMiddleware`の中ではもしパスがマッチするなら認証を確かめる設定をしています

```ts
export default clerkMiddleware(async (auth, req) => {
  if (isProtectedRoute(req)) {
    await auth.protect();
  }
});
```

auth.protect()をすることで認証済み以外のユーザーはアクセスできなくなります。
実際に確かめてみます。

[http://localhost:3000](http://localhost:3000)にアクセスすると認証画面にリダイレクトされます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/88fe53ac-e1d4-42b6-970c-87aeedb918b1.png)

実際にアカウントを作成します。今回はGoogleでログインしてみます。
「Sign up」から「Continue With Google」をクリック(リダイレクト先はSign inなので注意)

「Verfify You are Human」チェック入れてアカウントを選択して「次へ」をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/576a544d-32f1-493b-9545-0e9ce62fb739.png)

ログインができると`/`にリダイレクトされてHomeがみれました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/cd78b44f-a36c-4e23-bdc2-530ab9c8e579.png)

このままだとログインしたままになって検証がしづらいのでログアウトボタンをつけてみます。

```tsx:src/app/page.tsx
import { SignOutButton } from "@clerk/nextjs";
import React from "react";

function Home() {
  return (
    <div>
      <h1>Home</h1>
      <SignOutButton />
    </div>
  );
}

export default Home;
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/facf352b-e849-45e0-92f8-a7752f47ebc5.png)

「Sigin Out」をクリックするとログアウトできます。
Clerkには便利なコンポーネントがあり、`<SignOutButton>`はログアウトを実装できるものです。

# 4.ユーザー情報をDBに保存する

ここからはClerkのWebHookという機能を使ってClerkで認証をしたらDBにもユーザーデータを保存する仕組みを実装します。

まずは今回実装するWebHookの仕組みから解説していきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/e2aa24d9-df45-40f2-bdb8-183c0e00dd1b.png)

ClerkにはWebHookという仕組みがあり、「Sign UP」(ユーザー作成)が行われたら、`/api/webhook/clerk`を叩くように設定を行うことができます。

`/api/clerk/webhook`には作成されたユーザーの情報を渡します。
そして`/api/clerk/webhook`のAPIの中でDBにユーザー情報を保存する処理が動いて保存が完了します。

実際にこの流れで設定を行っていきます。まずはClerkを開いてください
「Configure」から「Webhooks」を開いて「Add Endpoint」をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/ff119d96-3f08-462a-85e4-e6520598b5b1.png)

Endpoint URLには実際にClerkが叩くエンドポイント
Subscribe to eventにはどの操作が行われたときにエンドポイントを叩くかを設定します。

Endpoint URL : `http://localhost:3000/api/webhook/clerk`
Subscribe to event : `user.created`

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/72de3225-0edd-4b02-9494-2e7ad1e82c97.png)

「Create」を押すとWebHookが作成できるはずですがエラーになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/63f145e2-7601-4a83-acf5-addc80f8a898.png)

`localhost:3000`としてしまうとClerk側のホストの`localhost:3000`という意味になってしまい私たちのアプリのAPIを叩けません。つまりアプリをデプロイしてあげる必要があります。

今回はデプロイすると開発が止まってしまうため`ngrok`を使ってポートフォワードをします。ngrokの解説をしていきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/6970b223-424e-4525-a2c4-328317b0957e.png)

ポートフォワードという仕組みをngrokでおこなうことで、ngrokのエンドポイントを叩くと私たちのローカルのエンドポイントにプロキシ(中継)をしてくれてlocalhostのAPIを叩けるようになります。

それでは`ngrok`をインストールします。それぞれの環境にあった方法でインストールしてください。
初回にはトークン認証も必要です。難しくないので調べて進めましょう

https://ngrok.com/

それでは実際にポートフォワードをしてみましょう

```
$ ngrok http 3000

Session Status                online                                                                        
Account                       Watanabe Jin (Plan: Free)                                                     
Update                        update available (version 3.21.0, Ctrl-U to update)                           
Version                       3.20.0                                                                        
Region                        Japan (jp)                                                                    
Web Interface                 http://127.0.0.1:4040                                                         
Forwarding                    https://ad2e-113-43-203-90.ngrok-free.app -> http://localhost:3000    
```

`https://ad2e-113-43-203-90.ngrok-free.app`にリクエストすると`http://localhost:3000`にポートフォワードされるようになりました。(人それぞれURLは違います)

実際にこのURLでアクセスしてみるとログイン画面が表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/a835fe85-99e5-4d73-9aa9-07fe546a7c04.png)

※ ただし実際にログインはClerkの関係で使えないので注意

それではこのURLをWebHookとして設定しましょう

EndPoint URL : `[あなたのURL]/api/webhook/clerk`


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/efe24e06-0d3d-4336-b9ff-871804a73a33.png)

「Create」をおして設定は完了です。ngrokを切るとURLが変わるので切らないようにしてください。

# 5. JStackでAPIを開発する

ここからはJStackの機能の一つである型セーフなAPI開発を行っていきます。
JStackでは`src/server`にAPIに関するファイルを作成します。
まずは試しに簡単なAPIを作ってみましょう

```
$ touch src/server/routers/ping-router.ts
```

```ts:ping-router.ts
import { j, publicProcedure } from "../jstack";

export const pingRouter = j.router({
  ping: publicProcedure.get(({ c }) => {
    return c.json({ message: "Pong!" });
  }),
});
```

JStackでは`j.router`にルーティングの設定をすることで型安全などの恩恵を受けることができます。

```ts
  ping: publicProcedure.get(({ c }) => {
    return c.json({ message: "Pong!" });
  }),
```

`/ping`のエンドポイントを作る例です。`publicProcedure`を使うことでミドルウェアのようなものをJStackでも簡単に実装することも可能なようです。

APIを実装したのでこれを使えるように設定してきます。

```ts:src/server/index.ts
import { j } from "./jstack";
import { pingRouter } from "./routers/ping-router";
import { postRouter } from "./routers/post-router";

/**
 * This is your base API.
 * Here, you can handle errors, not-found responses, cors and more.
 *
 * @see https://jstack.app/docs/backend/app-router
 */
const api = j
  .router()
  .basePath("/api")
  .use(j.defaults.cors)
  .onError(j.defaults.errorHandler);

/**
 * This is the main router for your server.
 * All routers in /server/routers should be added here manually.
 */
const appRouter = j.mergeRouters(api, {
  post: postRouter,
  system: pingRouter,
});

export type AppRouter = typeof appRouter;

export default appRouter;
```

`j.mergeRouters`に`api`の基本設定とルーティングを渡します。

```ts
const api = j
  .router()
  .basePath("/api")
  .use(j.defaults.cors)
  .onError(j.defaults.errorHandler);
```

APIの基本設定はCORSや`/api`からエンドポイントが始まることが設定されています。

```ts
const appRouter = j.mergeRouters(api, {
  post: postRouter,
  systems: pingRouter,
});
```

実際のルーティングにはすでに最初から実装されている`/post`と今回追加した`pingRouter`を設定します。

```ts
  system: pingRouter,
```

system:とすることで`/system`となります。pingRouterでは

```ts
ping: publicProcedure.get(({ c }) => {
```

としているのでAPIは`/api/systems/ping`とすると叩けるはずです。

```
$ curl localhost:3000/api/systems/ping
{"message":"Pong!"}
```

ngrokでポートフォワードしたURLでも叩けるかを確認しましょう

```
$ curl あなたのngrokのURL/api/systems/ping
{"message":"Pong!"}
```

ここでAPIにも将来的に認証していないとフロントエンドから叩けないようにしたいので設定をClerkにいれます。

```ts:src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";

const isProtectedRoute = createRouteMatcher(["/"]);
const isWebhookRoute = createRouteMatcher(["/api/webhook/clerk(.*)"]);
const isPingRoute = createRouteMatcher(["/api/systems/ping(.*)"]);

export default clerkMiddleware(async (auth, req) => {
  if (isWebhookRoute(req) || isPingRoute(req)) {
    return;
  }

  if (isProtectedRoute(req)) {
    await auth.protect();
  }
});

export const config = {
  matcher: [
    // Skip Next.js internals and all static files, unless found in search params
    "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
    // Always run for API routes
    "/(api|trpc)(.*)",
  ],
};
```

今回はホワイトリスト形式で叩けるものは`return`することで認証の対象から外しました。

それでは`/api/webhook/clerk`のAPIを作成しましょう。
実装の内容は公式ドキュメントを参考にしていきます。

https://clerk.com/docs/webhooks/sync-data

まずはWebHookが正しいリクエストなのか？(悪意を持ったAPIリクエストでないか)を検証するのに使えるライブラリを入れます。

```
$ npm install svix
```

次にDBにテーブルを用意したいので`schema.ts`を更新してDBに反映させます。

```ts:src/server/db/schema.ts
import { pgTable, serial, text, timestamp, index } from "drizzle-orm/pg-core";

export const posts = pgTable(
  "posts",
  {
    id: serial("id").primaryKey(),
    name: text("name").notNull(),
    createdAt: timestamp("createdAt").defaultNow().notNull(),
    updatedAt: timestamp("updatedAt").defaultNow().notNull(),
  },
  (table) => [index("Post_name_idx").on(table.name)]
);

export const users = pgTable(
  "users",
  {
    id: serial("id").primaryKey(),
    clerkId: text("clerkId").notNull(),
    email: text("email").notNull(),
    name: text("name").notNull(),
    handle: text("handle").notNull(),
    avatarUrl: text("avatarUrl"),
    bio: text("bio"),
    createdAt: timestamp("createdAt").defaultNow().notNull(),
    updatedAt: timestamp("updatedAt").defaultNow().notNull(),
  },
  (table) => [index("User_email_idx").on(table.email)]
);
```

ユーザーテーブルは以下の要素を持っています。

|column|内容|
|---|---|
|id|識別子|
|clerkId|clerkが持つid|
|email|メールアドレス|
|name|名前|
|handle|ハンドルネーム(@から始まるやつ)|
|avatarUrl|アバター画像のURL|
|bio|自己紹介文章|
|createdAt|作成日|
|updatedAt|更新日|

それではテーブルの型情報の出力とNeonへの更新をしましょう

```
$ npm run db:generate
$ npm run db:push
```

Neonをみてテーブルができていれば成功です。ClerkでSign upしたらここにユーザーレコードが作成されることを目指してAPIを開発しましょう

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/8cee7964-2b3d-44cf-a6ce-306572625e2c.png)


次にAPIを実装するためのファイルをつくります。

```
$ touch src/server/routers/clerk-webhook-router.ts
```

```ts:clerk-webhook-router.ts
import { j, publicProcedure } from "../jstack";
import { WebhookEvent } from "@clerk/nextjs/server";
import { Webhook } from "svix";
import { users } from "@/server/db/schema";

export const clerkWebhookRouter = j.router({
  clerk: publicProcedure.post(async ({ c, ctx }) => {
    const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET;
    const { db } = ctx;

    if (!WEBHOOK_SECRET) {
      throw new Error("CLERK_WEBHOOK_SECRET is not set");
    }

    const payload = await c.req.text();
    const headers = c.req.raw.headers;

    const svixHeaders = {
      "svix-id": headers.get("svix-id") || "",
      "svix-timestamp": headers.get("svix-timestamp") || "",
      "svix-signature": headers.get("svix-signature") || "",
    };

    const wh = new Webhook(WEBHOOK_SECRET);
    let evt: WebhookEvent;

    try {
      evt = wh.verify(payload, svixHeaders) as WebhookEvent;
    } catch (err) {
      console.error("Webhook verification failed", err);
      return c.json({ error: "Webhook verification failed" }, 400);
    }

    const eventType = evt.type;

    if (eventType === "user.created") {
      const { id, email_addresses, first_name, last_name } = evt.data;
      console.log(`User ${id} was created`);

      await db.insert(users).values({
        clerkId: id,
        email: email_addresses[0]?.email_address ?? "",
        name: `${first_name || ""} ${last_name || ""}`.trim(),
        avatarUrl: "",
        bio: "",
        handle: email_addresses[0]?.email_address.split("@")[0] ?? "",  
      });
    }

    return c.json({ message: "Webhook received successfully" }, 200);
  }),
});
```

ここはドキュメント通りではありますがざっくりと解説はしていきます。

まずはコンテキストからdbを操作できる`db`を受け取ります。これはJStackを使っていれば`ctx`から受け取れるものです。dbを使うことで簡単に`drizzle`を利用できます。


```ts
    const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET;
    const { db } = ctx;

    if (!WEBHOOK_SECRET) {
      throw new Error("CLERK_WEBHOOK_SECRET is not set");
    }
```

WEBHOOK_SERCRETはClerkのWebHooksから取得できるので.envに設定しましょう

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/73a4d06e-2edb-414f-95e9-5ea8080d74e5.png)

「Signing Secert」からコピーして.envに追加

```.env
DATABASE_URL=あなたのDATABASE_URL
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=あなたのNEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
CLERK_SECRET_KEY=あなたのCLERK_SECRET_KEY
CLERK_WEBHOOK_SECRET=あなたのCLERK_WEBHOOK_SECRET
```

次にWebHooksが正しい情報かを検証するところです。

```ts
    const payload = await c.req.text();
    const headers = c.req.raw.headers;

    const svixHeaders = {
      "svix-id": headers.get("svix-id") || "",
      "svix-timestamp": headers.get("svix-timestamp") || "",
      "svix-signature": headers.get("svix-signature") || "",
    };

    const wh = new Webhook(WEBHOOK_SECRET);
    let evt: WebhookEvent;

    try {
      evt = wh.verify(payload, svixHeaders) as WebhookEvent;
    } catch (err) {
      console.error("Webhook verification failed", err);
      return c.json({ error: "Webhook verification failed" }, 400);
    }
```

ヘッダーとペイロード(送られてきた情報)を使ってWebHooksに検証のリクエストをしています

```ts
      evt = wh.verify(payload, svixHeaders) as WebhookEvent;
```

問題が起きるとエラーとなり処理は終了します。
正しいリクエストであれば送られてくるイベントタイプをチェックします。

```ts
    const eventType = evt.type;

    if (eventType === "user.created") {
      const { id, email_addresses, first_name, last_name } = evt.data;
      console.log(`User ${id} was created`);

      await db.insert(users).values({
        clerkId: id,
        email: email_addresses[0]?.email_address ?? "",
        name: `${first_name || ""} ${last_name || ""}`.trim(),
        avatarUrl: "",
        bio: "",
        handle: email_addresses[0]?.email_address?.split("@")[0] ?? "",,
      });
    }
```

今回は`user.created`をWebHooksで選択したので、eventTypeに送られてきます。
もし`user.created`ならDBにインサートする処理を行います。

ハンドルはメールアドレスの@以前を初期値に設定しています。

それではAPIを設定します。

```ts:src/server/index.ts
import { j } from "./jstack";
import { clerkWebhookRouter } from "./routers/clerk-webhook-router";
import { pingRouter } from "./routers/ping-router";
import { postRouter } from "./routers/post-router";

/**
 * This is your base API.
 * Here, you can handle errors, not-found responses, cors and more.
 *
 * @see https://jstack.app/docs/backend/app-router
 */
const api = j
  .router()
  .basePath("/api")
  .use(j.defaults.cors)
  .onError(j.defaults.errorHandler);
  
/**
 * This is the main router for your server.
 * All routers in /server/routers should be added here manually.
 */
const appRouter = j.mergeRouters(api, {
  post: postRouter,
  systems: pingRouter,
  webhook: clerkWebhookRouter, // 追加
});

export type AppRouter = typeof appRouter;

export default appRouter;
```

それでは実際にWebHookが正しく機能するか(APIと疎通できるか)をチェックします。
Clerkのダッシュボードを開きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/ec105467-d800-464c-88fe-bda7cdb605c1.png)


「Testing」を選択してSend Eventを`user.created`にします。
「Send Example」を送信して`Successed`になれば問題ないです。エラーが出る場合はコンソールのログをみて対応してください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/d02dfe31-df58-4687-977e-b13168f8ae9a.png)

それでは実際にClerkを使ってログインをしてDBにユーザー情報が保存できるかを確かめます。
先程Googleログインしたアカウントは削除します。Clerkの「User」を開いてください

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/87375f23-6b43-4dd1-abad-63898a676e47.png)

三点リーダーから「Delete」を押して「Delte」を選択すると削除できます。
同じユーザーはsing upができないので削除を行っています。

それでは実際に[http://localhost:3000](http://localhost:3000)からログインをしてみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/a7dd34ef-f1a9-4d61-90b0-15990a3fa607.png)


Neonをみるとユーザーテーブルにデータが追加されていることがわかります。

# 6. タイムライン画面を作成する

次にタイムライン画面を実装していきます。
この画面はコンポーネントとして「サイドメニュー」「ポスト投稿フォーム」「投稿を表示するタイムライン」の3つにコンポーネントをわけて実装します。

サイドメニューはすべてで共通なのでレイアウトに追加します。

```
$ touch src/components/PostForm.tsx
$ touch src/components/PostList.tsx
$ touch src/components/SideMenu.tsx
```

```tsx:PostForm.tsx
import React from "react";

function PostForm() {
  return <div>PostForm</div>;
}

export default PostForm;
```

```tsx:PorstList.tsx
import React from "react";

function PostList() {
  return <div>PostList</div>;
}

export default PostList;
```

```tsx:src/app/components/SideMenu.tsx
import { SignOutButton } from "@clerk/nextjs";
import React from "react";

function SideMenu() {
  return (
    <div>
      <SignOutButton />
    </div>
  );
}

export default SideMenu;
```

```tsx:src/app/page.tsx
import PostForm from "@/components/PostForm";
import PostList from "@/components/PostList";
import React from "react";

function Home() {
  return (
    <div className="max-w-xl mx-auto min-h-screen">
      <div>
        <PostForm />
      </div>
      <PostList />
    </div>
  );
}

export default Home;
```

```tsx:src/app/layout.tsx
import type { Metadata } from "next";
import { ClerkProvider, SignedIn, SignedOut } from "@clerk/nextjs";
import "./globals.css";
import SideMenu from "@/components/SideMenu";

export const metadata: Metadata = {
  title: "JStack Twitter Clone",
  description: "Twitter clone created using JStack",
  icons: [{ rel: "icon", url: "/favicon.ico" }],
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body className="antialiased">
          <div className="flex min-h-screen">
            <SideMenu />
            <main className="flex-1 transition-all duration-300">
              {children}
            </main>
          </div>
        </body>
      </html>
    </ClerkProvider>
  );
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/a4c8b4ab-a278-4827-9bde-403edb1f7053.png)

レイアウトだけなのでまずは投稿を表示する機能だけ作成します。

`posts`テーブルを作成しましょう。実はもうすでにデフォルトで用意されているので修正をしてDBに反映していきます。

```ts:src/server/db/schema.ts
import {
  pgTable,
  serial,
  text,
  timestamp,
  index,
  integer,
} from "drizzle-orm/pg-core";

export const users = pgTable(
  "users",
  {
    id: serial("id").primaryKey(),
    clerkId: text("clerkId").notNull(),
    email: text("email").notNull(),
    name: text("name").notNull(),
    handle: text("handle").notNull(),
    avatarUrl: text("avatarUrl"),
    bio: text("bio"),
    createdAt: timestamp("createdAt").defaultNow().notNull(),
    updatedAt: timestamp("updatedAt").defaultNow().notNull(),
  },
  (table) => [index("User_email_idx").on(table.email)]
);

export const posts = pgTable(
  "posts",
  {
    id: serial("id").primaryKey(),
    content: text("content").notNull(),
    handle: text("handle").notNull(),
    like: integer("like").notNull().default(0),
    image: text("image"),
    createdAt: timestamp("createdAt").defaultNow().notNull(),
    updatedAt: timestamp("updatedAt").defaultNow().notNull(),
  },
  (table) => [index("Post_userId_idx").on(table.handle)]
);
```

あとでプロフィール画面でユーザーの投稿だけを表示しやすくするためにユーザーとポストはハンドルネームを使って紐付けるようにしました

```tsx
(table) => [index("Post_userId_idx").on(table.handle)]
```

ではテーブルの型情報を出力します。

```
$ npm run db:generate

~ name › content column will be renamed

+ handle column will be created

+ like column will be created

+ image column will be created
--- all columns conflicts in posts table resolved ---
```

以前との変更箇所を聞かれるので合わせていきます。
次にDBに変更を反映します。

```
$ npm run db:push
~ name › content column will be renamed

+ handle column will be created

+ like column will be created

+ image column will be created
--- all columns conflicts in posts table resolved ---

 Warning  Found data-loss statements:
· You're about to add not-null handle column without default value, which contains 3 items

THIS ACTION WILL CAUSE DATA LOSS AND CANNOT BE REVERTED

Do you still want to push changes?
[✓] Changes applied
```

ここも同じく変更を一つずつ聞かれます。そのあとにレコードあるけど消していい？と聞かれるので消しましょう。
テーブルのスキーマが違うレコードがあるとおかしくなるため削除しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/cc74daa1-ace9-4023-9394-3a3985a2e40f.png)

最初に作成したレコードは全て消えていて、カラムも新しくなっていることがわかります。
次にテストデータをいくつか追加しましょう。

「Add record」をクリックすると追加できるので以下のデータを作成してください

データ1

|項目|値|
|---|---|
|id|DEFAULT|
|content|Hello World|
|createdAt|DEFAULT|
|updatedAt|DEFAULT|
|hanlde|hoge|
|like|10|
|image|NULL|

入力して「Save 1 Chnage」を押すと保存されます。

データ2

|項目|値|
|---|---|
|id|DEFAULT|
|content|Hello React|
|createdAt|DEFAULT|
|updatedAt|DEFAULT|
|hanlde|react|
|like|100|
|image|NULL|

データ3

|項目|値|
|---|---|
|id|DEFAULT|
|content|こんにちは|
|createdAt|DEFAULT|
|updatedAt|DEFAULT|
|hanlde|japan|
|like|200|
|image|NULL|

3つのレコードが作成されればOKです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/6d9639b1-22a5-4ab6-a50f-7a568aeb35c6.png)

それでは実際にこのレコードをJStackを使って表示しましょう
まずはAPIから作成していきます。

```ts:src/server/routers/post-router.ts
import { posts, users } from "@/server/db/schema";
import { desc, eq } from "drizzle-orm";
import { j, publicProcedure } from "../jstack";

export const postRouter = j.router({
  all: publicProcedure.query(async ({ c, ctx }) => {
    const { db } = ctx;

    const postsData = await db
      .select({
        id: posts.id,
        content: posts.content,
        handle: users.handle,
        name: users.name,
        like: posts.like,
        image: posts.image,
        createdAt: posts.createdAt,
        avatarUrl: users.avatarUrl,
      })
      .from(posts)
      .innerJoin(users, eq(users.handle, posts.handle))
      .orderBy(desc(posts.createdAt));
    return c.superjson(postsData);
  }),
});
```

clerkIdやemailは不要なので除いて必要な要素だけを取得しています。
userテーブルのhandleとnameの情報がほしいので`hanlde`キーでusersテーブルとpostsテーブルを結合しています。

drizzleを使って作成日順の降順でソートしてレスポンスを返します。

```tsx
    const postsData = await db
      .select({
        id: posts.id,
        content: posts.content,
        handle: users.handle,
        name: users.name,
        like: posts.like,
        image: posts.image,
        createdAt: posts.createdAt,
        avatarUrl: users.avatarUrl,
      })
      .from(posts)
      .innerJoin(users, eq(users.handle, posts.handle))
      .orderBy(desc(posts.createdAt));
    return c.superjson(postsData);
```

ここでJStackを利用することでテーブルのスキーマからusersやpostsの補完が効いていることもわかります。(これはgenerateでテーブルの情報を型に出力して利用しているからです)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/a5335068-9820-4e92-b4a5-f9225cb7cecd.png)

それでは実際に叩いてみましょう

```
$ curl localhost:3000/api/post/all
{"json":[]}
```

なぜかデータが帰ってきません。これはユーザーとポストを`handle`で結びつけられていないからです。
とりあえずpostsのhanldeをすべてユーザーのhandleに更新しましょう(人それぞれ異なります)

handleはログインしているメールアドレスの@以前が設定されています

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/abc0d1d9-ead6-415d-bd59-8702aa1392d3.png)


私の場合は`handle`が`jin.watanabe.6g`なのでこの値をpostsの3つのデータのhandleに入れて更新します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/339e44ee-50ce-4ae3-a99b-ee7c7e17dd4b.png)

もう一度APIを叩くと正しくデータが返ってきます。

```
$ curl localhost:3000/api/post/all
{"json":[{"id":6,"content":"こんにちは","handle":"jin.watanabe.6g","name":"臣 渡邉","like":200,"image":null,"createdAt":"2025-03-15T06:26:48.272Z","avatarUrl":""},{"id":5,"content":"Hello React","handle":"jin.watanabe.6g","name":"臣 渡邉","like":100,"image":"","avatarUrl":""},{"id":4,"content":"Hello World","handle":"jin.watanabe.6g","name":"臣 渡邉","like":10,"image":null,"createdAt":"2025-03-15T06:25:03.720Z","avatarUrl":""}],"meta":{"values":{"0.createdAt":["Date"],"1.createdAt":["Date"],"2.createdAt":["Date"]}}}
```

次にこのAPIを最初に叩いてタイムライン表示をします。

まずはドメインを作成します。

```
$ mkdir src/domain
$ touch src/domain/Post.ts
```

```ts:Post.ts
export type Post = {
  id: number;
  content: string;
  like: number;
  handle: string;
  image: string | null;
  createdAt: Date | string;
  avatarUrl: string | null;
  name: string;
};
```

私たちがこのアプリで扱う投稿をデータ型で表現しました。

```tsx:PostList.tsx
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/lib/client";
import { Post } from "@/domain/Post";

export default function PostList() {
  const { data: posts, isLoading } = useQuery<Post[]>({
    queryKey: ["posts"],
    queryFn: async () => {
      const res = await client.post.all.$get();
      const data = await res.json();
      return data as Post[];
    },
  });

  if (isLoading || !posts) {
    return <p>Loading...</p>;
  }

  return (
    <div className="divide-y divide-gray-100">
      {posts.map((post: Post) => (
        <div key={post.id}>
          <p>{post.content}</p>
          <p>{post.like}</p>
          <p>{post.image}</p>
          <p>{post.name}</p>
          <p>{post.handle}</p>
        </div>
      ))}
    </div>
  );
}
```

データ取得には`React Query`を利用するためクライアントサイドであることを明示的に指定します。

```tsx
"use client";
```

`useQuery`を使ってデータ取得をします。

```tsx
  const queryClient = useQueryClient();

  const { data: posts, isLoading } = useQuery<Post[]>({
    queryKey: ["posts"],
    queryFn: async () => {
      const res = await client.post.all.$get();
      const data = await res.json();
      return data as Post[];
    },
  });
```

`queryKey`は「posts」というキーでクエリ結果がキャッシュされることを表現しています。
同じコンポーネントや他のコンポーネントで同じキーを使用すると、重複したAPIリクエストが発生せず、キャッシュされたデータが使用されます

`queryFn`で実際の取得処理を実装しています。

```tsx
const res = await client.post.all.$get();
```

JStackが提供する`client`を利用することでAPIの返却の型情報を利用することができます。
`data`にホバーをすると正しく型情報が表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/7f4acd56-6c58-4998-bd88-22aa2af44519.png)

これでフロントエンドからバックエンドまで型安全に開発が可能です。
dataはPost[]になっているのですが最後に`as Post[]`をつけないとuseQueryで怒られるのでいれておきます。

ローディング状態またはpostsがないときはローディング表示を出しておきます。

```tsx
  if (isLoading || !posts) {
    return <p>Loading...</p>;
  }
```

それでは実際に画面を確認しましょう

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/6ee4bff6-ec55-4ff2-8352-63778d179b5b.png)

QuryClientProviderを設定しろと怒られているので設定します。

```
$ touch src/components/Provider.tsx
```

```tsx:src/components/Provider.tsx
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PropsWithChildren } from "react";

const queryClient = new QueryClient();

export const QueryProviders = ({ children }: PropsWithChildren) => {
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
};
```

layoutに設定してどのページでもReactQueryが使えるようにしましょう

```tsx:src/app/layout.tsx
import type { Metadata } from "next";
import { ClerkProvider, SignedIn, SignedOut } from "@clerk/nextjs";
import "./globals.css";
import SideMenu from "@/components/SideMenu";
import { Providers } from "./components/providers";

export const metadata: Metadata = {
  title: "JStack Twitter Clone",
  description: "Twitter clone created using JStack",
  icons: [{ rel: "icon", url: "/favicon.ico" }],
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body className="antialiased">
          <div className="flex min-h-screen">
            <SideMenu />
            <main className="flex-1 transition-all duration-300">
              {/* 追加 */}
              <Providers>{children}</Providers>
            </main>
          </div>
        </body>
      </html>
    </ClerkProvider>
  );
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/4adb60e8-46c7-4d00-a192-72cd61603c7e.png)

投稿のデータが表示されるようになりました。
JStackを使うことで簡単にAPIとの連携ができることがわかります。

# 7. 新しいポストを投稿しよう

次にポストを作成できる画面を実装します。
ここではポストの投稿のあとにCloudinaryを利用した画像保存についても学びます。

まずはCloudinaryのアカウントを作成してください。

https://cloudinary.com/

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/1301cce3-974a-4edb-be4d-128bc7932014.png)

「Go to API Keys」(またはView API Keys)をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/41531958-fa1e-4a4b-96d2-52b922f2bfe7.png)

「Generate New API Key」をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/b24fb5c1-c270-42f1-8bec-2b948d7be7d1.png)

作成できたら「Cloud name」と「API Key」と「API Secret」の値をコピーして.envに貼り付けます

```.env
DATABASE_URL=あなたのDATABASE_URL
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=あなたのNEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
CLERK_SECRET_KEY=あなたのCLERK_SECRET_KEY
CLERK_WEBHOOK_SECRET=あなたのCLERK_WEBHOOK_SECRET
CLOUDINARY_CLOUD_NAME=あなたのCLOUDINARY_CLOUD_NAME
CLOUDINARY_API_KEY=あなたのCLOUDINARY_API_KEY
CLOUDINARY_API_SECRET=あなたのCLOUDINARY_API_SECRET
```

Cloudinaryに画像をアップロードする関数を作成します。

```
$ npm i cloudinary
$ touch src/lib/cloudinary.ts
```

```ts:src/lib/cloudenary.ts
import { v2 as cloudinary } from "cloudinary";

cloudinary.config({
  cloud_name: process.env.CLOUDINARY_CLOUD_NAME || "",
  api_key: process.env.CLOUDINARY_API_KEY || "",
  api_secret: process.env.CLOUDINARY_API_SECRET || "",
});

export async function uploadImage(file: string): Promise<string> {
  try {
    const result = await cloudinary.uploader.upload(file, {
      folder: "twitter-clone",
    });
    return result.secure_url;
  } catch (error) {
    console.error("Cloudinaryへのアップロードエラー:", error);
    throw new Error("画像のアップロードに失敗しました");
  }
}
```

シンプルな関数です。今回は`twitter-clone`というディレクトリに画像を保存しています。

```ts
    const result = await cloudinary.uploader.upload(file, {
      folder: "twitter-clone",
    });
```


APIを作成します。

```ts:src/server/routers/post-router.ts
import { posts, users } from "@/server/db/schema";
import { desc, eq } from "drizzle-orm";
import { j, publicProcedure } from "../jstack";
import { z } from "zod";
import { uploadImage } from "@/lib/cordinary";

export const postRouter = j.router({
  all: publicProcedure.query(async ({ c, ctx }) => {
    const { db } = ctx;

    const postsData = await db
      .select({
        id: posts.id,
        content: posts.content,
        handle: users.handle,
        name: users.name,
        like: posts.like,
        image: posts.image,
        createdAt: posts.createdAt,
        avatarUrl: users.avatarUrl,
      })
      .from(posts)
      .innerJoin(users, eq(users.handle, posts.handle))
      .orderBy(desc(posts.createdAt));
    return c.superjson(postsData);
  }),

  // 追加
  create: publicProcedure
    .input(
      z.object({
        content: z.string().min(1),
        handle: z.string(),
        image: z.string().optional(),
      })
    )
    .mutation(async ({ ctx, c, input }) => {
      const { content, handle, image } = input;
      const { db } = ctx;

      let imageUrl = null;
      if (image) {
        try {
          imageUrl = await uploadImage(image);
        } catch (error) {
          console.error("Error uploading image:", error);
        }
      }

      const post = await db.insert(posts).values({
        content,
        handle,
        image: imageUrl,
      });

      return c.superjson(post);
    }),
});
```

`/api/posts/create`を追加しました。
まずはZodを使って入力値の検証をしています。もしcontentやhandleが送られていないとエラーになります。

```tsx
    .input(
      z.object({
        content: z.string().min(1),
        handle: z.string(),
        image: z.string().optional(),
      })
    )
```

次に実際の処理になりますが`mutation`を使っています。

```tsx
.mutation(async ({ ctx, c, input }) => {
```

GETリクエストのときは`query`、POSTのときは`mutation`を使います。
あとは画像があればCloudinaryにアップロードしてDBのインサートをするだけです。

```tsx
      if (image) {
        try {
          imageUrl = await uploadImage(image);
        } catch (error) {
          console.error("Error uploading image:", error);
        }
      }

      const post = await db.insert(posts).values({
        content,
        handle,
        image: imageUrl,
      });
```

投稿フォームのコンポーネントを作成しましょう
まずはログイン情報を表すドメインを用意します。

```
$ touch src/domain/User.ts
```

```ts:src/domain/User.ts
export type UserProfile = {
  id: number;
  clerkId: string;
  email: string;
  name: string;
  handle: string;
  avatarUrl: string | null;
  bio: string | null;
  createdAt: string;
  updatedAt: string;
};
```

次にフォームを作ります。

```tsx:src/components/PostForm.tsx
"use client";
import { useState, useRef } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/lib/client";
import Image from "next/image";
import { UserProfile } from "@/domain/User";

export default function PostForm({ user }: { user: UserProfile }) {
  const [content, setContent] = useState("");
  const [image, setImage] = useState<string | null>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);
  const queryClient = useQueryClient();

  const createPostMutation = useMutation({
    mutationFn: async (newPost: {
      content: string;
      handle: string;
      image?: string;
    }) => {
      const res = await client.post.create.$post(newPost);
      return await res.json();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["posts"] });
      setContent("");
      setImage(null);
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (content.trim()) {
      createPostMutation.mutate({
        content,
        handle: user.handle,
        image: image || undefined,
      });
    }
  };

  const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) {
      const reader = new FileReader();
      reader.onloadend = () => {
        const base64String = reader.result as string;
        setImage(base64String);
      };
      reader.readAsDataURL(file);
    }
  };

  const handleImageButtonClick = () => {
    fileInputRef.current?.click();
  };

  return (
    <form onSubmit={handleSubmit} className="p-4">
      <div className="flex gap-3">
        <div className="flex-shrink-0">
          {user.avatarUrl ? (
            <Image
              src={user.avatarUrl}
              alt={user.name}
              width={40}
              height={40}
              className="rounded-full"
            />
          ) : (
            <div className="w-10 h-10 rounded-full bg-gray-200"></div>
          )}
        </div>
        <div className="flex-grow">
          <textarea
            value={content}
            onChange={(e) => setContent(e.target.value)}
            rows={3}
          />

          {image && (
            <Image src={image} alt="Upload preview" width={500} height={300} />
          )}

          <div>
            <button type="button" onClick={handleImageButtonClick}>
              画像
            </button>
            <input
              ref={fileInputRef}
              type="file"
              accept="image/*"
              className="hidden"
              onChange={handleImageSelect}
            />
          </div>

          <button type="submit">投稿する</button>
        </div>
      </div>
    </form>
  );
}
```

このコンポーネントでは親コンポーネントからログインユーザーの情報を受け取ります。

```tsx
export default function PostForm({ user }: { user: UserProfile }) {
```

投稿のデータにハンドルネームを含めるためです。

```tsx
  const queryClient = useQueryClient();

  const createPostMutation = useMutation({
    mutationFn: async (newPost: {
      content: string;
      handle: string;
      image?: string;
    }) => {
      const res = await client.post.create.$post(newPost);
      return await res.json();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["posts"] });
      setContent("");
      setImage(null);
    },
  });
```

今回もReactQueryを使いますが、今回はデータ作成のときに利用するので`useMutation`を使います。
mutationFnに作成の処理を書きます。

```tsx
    mutationFn: async (newPost: {
      content: string;
      handle: string;
      image?: string;
    }) => {
      const res = await client.post.create.$post(newPost);
      return await res.json();
    },
```

React QueryはmtationFnが成功したときの処理を書くことができます。
ここでは新規作成できたらデータを再取得するようにしています。こうすることでタイムラインが更新されます。

```tsx
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["posts"] });
      setContent("");
      setImage(null);
    },
  });
```

ここでのポイントは`queryKey`に`posts`を選択していることです。
PostListコンポーネントでも同じキーを使っているので、PostListコンポーネントでも再fetchが走って更新されるのです。

フォームで入力されたContentとImageはステートで管理しているので投稿できたら空にしてあげます。

```tsx
        <div className="flex-grow">
          <textarea
            value={content}
            onChange={(e) => setContent(e.target.value)}
            rows={3}
          />
```

投稿のContentのフォームは入力される度にContentのステートに入力した値を保存しています。

```tsx
          <div>
            <button type="button" onClick={handleImageButtonClick}>
              画像
            </button>
            <input
              ref={fileInputRef}
              type="file"
              accept="image/*"
              className="hidden"
              onChange={handleImageSelect}
            />
          </div>
```

画像の投稿は画像ボタンを押すと`handleImageButtonClick`が実行されます。

```tsx
  const fileInputRef = useRef<HTMLInputElement>(null);
  
  (省略)
  
  const handleImageButtonClick = () => {
    fileInputRef.current?.click();
  };
  (省略)

            <input
              ref={fileInputRef}
```

クリックすると`fileInputRef`をクリックします。fileInputRefはrefでインプットフォームと結びついているのでファイル選択のモーダルを開くことができます。ファイルを選択すると`handleImageSelect`が実行されます。

```tsx
  const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) {
      const reader = new FileReader();
      reader.onloadend = () => {
        const base64String = reader.result as string;
        setImage(base64String);
      };
      reader.readAsDataURL(file);
    }
  };
```

画像データがあればbase64にエンコードしてImageのステートに保持しておきます。
保存ボタンをおすと`submit`イベントが発火してformの`onSubmit`に設定した関数が実行されます。

```tsx
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (content.trim()) {
      createPostMutation.mutate({
        content,
        handle: user.handle,
        image: image || undefined,
      });
    }
  };
```

投稿内容とユーザーのハンドルネームと画像を`useMutation`に渡してAPIを叩いています。

次にユーザー情報をPostFormに渡したいのですが、Clerkの情報にはhandleがなくログイン認証したデータのclerkIdからusersのDBを検索してハンドルネームを取得する必要があります。

clerkIdからユーザーの情報を取得するAPIを作ります。

```
$ touch src/server/routers/profile-router.ts
```

```ts:src/server/routers/profile-router.ts
import { z } from "zod";
import { users } from "../db/schema";
import { j, publicProcedure } from "../jstack";
import { eq } from "drizzle-orm";

export const profileRouter = j.router({
  get: publicProcedure
    .input(
      z.object({
        userId: z.string(),
      })
    )
    .query(async ({ ctx, c, input }) => {
      const { db } = ctx;
      const { userId } = input;

      const user = await db
        .select()
        .from(users)
        .where(eq(users.clerkId, userId))
        .limit(1);

      return c.json(user[0] ?? null);
    }),
});
```

userIdがないと検索できないのでzodでバリデーションをかけます

```ts
  get: publicProcedure
    .input(
      z.object({
        userId: z.string(),
      })
    )
```

userIdをインプットから取得して検索をします。取得した1件を返しています。

```ts
     const { userId } = input;

      const user = await db
        .select()
        .from(users)
        .where(eq(users.clerkId, userId))
        .limit(1);

      return c.json(user[0] ?? null);
```

新しいrouterを作ったので登録をします。

```tsx:src/server/index.ts
import { j } from "./jstack";
import { clerkWebhookRouter } from "./routers/clerk-webhook-router";
import { pingRouter } from "./routers/ping-router";
import { postRouter } from "./routers/post-router";
import { profileRouter } from "./routers/profile-router";

/**
 * This is your base API.
 * Here, you can handle errors, not-found responses, cors and more.
 *
 * @see https://jstack.app/docs/backend/app-router
 */
const api = j
  .router()
  .basePath("/api")
  .use(j.defaults.cors)
  .onError(j.defaults.errorHandler);

/**
 * This is the main router for your server.
 * All routers in /server/routers should be added here manually.
 */
const appRouter = j.mergeRouters(api, {
  post: postRouter,
  systems: pingRouter,
  webhook: clerkWebhookRouter,
  profile: profileRouter, // 追加
});

export type AppRouter = typeof appRouter;

export default appRouter;
```

これでclerkIdからデータを取得できるようになりました
それではPostListの呼び出し方を変えます。

```tsx:src/app/page.tsx
"use client";
import PostForm from "@/components/PostForm";
import PostList from "@/components/PostList";
import { client } from "@/lib/client";
import { useAuth } from "@clerk/nextjs";
import { useQuery } from "@tanstack/react-query";
import React from "react";

function Home() {
  const { userId } = useAuth();

  const { data: user } = useQuery({
    queryKey: ["user", userId],
    queryFn: async () => {
      if (!userId) return null;
      const res = await client.profile.get.$get({ userId });
      return await res.json();
    },
  });

  if (!user) {
    return <div>Loading...</div>;
  }

  return (
    <div className="max-w-xl mx-auto min-h-screen">
      <div>
        <PostForm user={user} />
      </div>
      <PostList />
    </div>
  );
}

export default Home;
```

今回はAPIを叩くときに`userId`を渡しています。

```tsx
      const res = await client.profile.get.$get({ userId });
```

それでは実際に投稿してみましょう

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/caac3968-e375-4b24-a531-e9065bc9ea2e.png)

わかりずらいですが、クリックするとフォームが現れます。
「画像ファイルを選択」を押すと画像も選択できます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/18474bfc-2136-486f-b58d-0b5cedf7b8ea.png)

「投稿する」をクリックするとタイムラインに投稿が追加されます(画像をアップロードするので時間がかかります)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/8fdb293b-b896-4321-af98-bcf9cc7e036b.png)

問題なく投稿できてタイムラインも更新されました。

いまonSuccessがこのようになっています

```tsx
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["posts"] });
      setContent("");
      setImage(null);
    },
```

`queryKey`を`posts`にしている箇所が再取得されるのですがこのままだと誰かが投稿すると全ユーザーのタイムラインが更新されてしまいます。投稿は1秒に何件も行われるのが通常なので取得が連続で行われてしまいます。

そこでユーザーの認証情報を追加して投稿したユーザーだけが再取得されるようにしましょう

```tsx:src/componetns/PostForm.tsx
"use client";
import { useState, useRef } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/lib/client";
import Image from "next/image";
import { UserProfile } from "@/domain/User";

export default function PostForm({ user }: { user: UserProfile }) {
  const [content, setContent] = useState("");
  const [image, setImage] = useState<string | null>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);
  const queryClient = useQueryClient();

  const createPostMutation = useMutation({
    mutationFn: async (newPost: {
      content: string;
      handle: string;
      image?: string;
    }) => {
      const res = await client.post.create.$post(newPost);
      return await res.json();
    },
    onSuccess: () => {
      // 修正
      queryClient.invalidateQueries({ queryKey: ["posts", user.handle] });
      setContent("");
      setImage(null);
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (content.trim()) {
      createPostMutation.mutate({
        content,
        handle: user.handle,
        image: image || undefined,
      });
    }
  };

  const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) {
      const reader = new FileReader();
      reader.onloadend = () => {
        const base64String = reader.result as string;
        setImage(base64String);
      };
      reader.readAsDataURL(file);
    }
  };

  const handleImageButtonClick = () => {
    fileInputRef.current?.click();
  };

  return (
    <form onSubmit={handleSubmit} className="p-4">
      <div className="flex gap-3">
        <div className="flex-shrink-0">
          {user.avatarUrl ? (
            <Image
              src={user.avatarUrl}
              alt={user.name}
              width={40}
              height={40}
              className="w-10 h-10 rounded-full"
            />
          ) : (
            <div className="w-10 h-10 rounded-full bg-gray-200"></div>
          )}
        </div>
        <div className="flex-grow">
          <textarea
            value={content}
            onChange={(e) => setContent(e.target.value)}
            rows={3}
          />

          {image && (
            <Image src={image} alt="Upload preview" width={500} height={300} />
          )}

          <div>
            <button type="button" onClick={handleImageButtonClick}>
              画像
            </button>
            <input
              ref={fileInputRef}
              type="file"
              accept="image/*"
              className="hidden"
              onChange={handleImageSelect}
            />
          </div>

          <button type="submit">投稿する</button>
        </div>
      </div>
    </form>
  );
}
```

```tsx:src/components/PostList.tsx
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/lib/client";
import { Post } from "@/domain/Post";
import { UserProfile } from "@/domain/User";

// 修正
export default function PostList({ user }: { user: UserProfile }) {
  const queryClient = useQueryClient();

  const { data: posts, isLoading } = useQuery<Post[]>({
    queryKey: ["posts", user.handle], // 修正
    queryFn: async () => {
      const res = await client.post.all.$get();
      const data = await res.json();
      return data as Post[];
    },
  });

  if (isLoading || !posts) {
    return <p>Loading...</p>;
  }

  return (
    <div className="divide-y divide-gray-100">
      {posts.map((post: Post) => (
        <div key={post.id}>
          <p>{post.content}</p>
          <p>{post.like}</p>
          <p>{post.image}</p>
          <p>{post.name}</p>
          <p>{post.handle}</p>
        </div>
      ))}
    </div>
  );
}
```

PostListはUserの情報を受け取っていないので受け取るように直しました

```tsx
export default function PostList({ user }: { user: UserProfile }) {
```

そして`queryKey`を`posts`と`user.handle`の複合キーにしました

```tsx
    queryKey: ["posts", user.handle],
```

最後にPostListにユーザーを渡すように直します。

```tsx:src/app/page.tsx
"use client";
import PostForm from "@/components/PostForm";
import PostList from "@/components/PostList";
import { client } from "@/lib/client";
import { useAuth } from "@clerk/nextjs";
import { useQuery } from "@tanstack/react-query";
import React from "react";

function Home() {
  const { userId } = useAuth();

  const { data: user } = useQuery({
    queryKey: ["user", userId],
    queryFn: async () => {
      if (!userId) return null;
      const res = await client.profile.get.$get({ userId });
      return await res.json();
    },
  });

  if (!user) {
    return <div>Loading...</div>;
  }

  return (
    <div className="max-w-xl mx-auto min-h-screen">
      <div>
        <PostForm user={user} />
      </div>
      
      <PostList user={user} />
    </div>
  );
}

export default Home;
```

これで全ユーザーが更新されることは防げます。

# 8. サイドメニューのスタイリング

サイドメニューにスタイルをあてていきます。アイコンにlucide-reactを使うのでインストールします。

```
$ npm i lucide-react
```

shadcn/uiを使っているのでインストールします。

```
$ npx shadcn@latest add avatar
✔ How would you like to proceed? › Use --legacy-peer-deps
```

```tsx:src/components/SideMenu.tsx
"use client";

import Link from "next/link";
import {
  Search,
  ListTodo,
  Bookmark,
  User,
  Twitter,
  LogOut,
} from "lucide-react";
import { useClerk } from "@clerk/nextjs";
import { useState } from "react";

const menuItems = [
  { name: "Explore", icon: Search, href: "/explore" },
  { name: "Lists", icon: ListTodo, href: "/lists" },
  { name: "Bookmarks", icon: Bookmark, href: "/bookmarks" },
  { name: "Profile", icon: User, href: "/profile" },
];

export function SideMenu() {
  const { signOut } = useClerk();

  const handleLogout = async () => {
    await signOut();
  };

  return (
    <div className="fixed top-0 left-0 h-screen flex flex-col border-r w-64 py-4 px-2 bg-white z-30 overflow-y-auto shadow-md">
      <div className="px-4 mb-6">
        <Link href="/" className="flex items-center">
          <Twitter className="h-8 w-8 text-blue-500" />
        </Link>
      </div>

      <nav className="space-y-2 flex-1">
        {menuItems.map((item) => (
          <div key={item.name} className="flex items-center gap-4 px-4 py-3 text-lg rounded-full hover:bg-gray-100 transition-colors font-normal">
            <item.icon className="h-6 w-6" />
            <span>{item.name}</span>
          </div>
        ))}
      </nav>

      <div className="mt-auto px-4 mb-6">
        <button
          onClick={handleLogout}
          className="w-full text-left px-4 py-3 rounded-full hover:bg-red-100 transition-colors flex items-center gap-3 text-red-600 border border-red-200"
        >
          <LogOut className="h-5 w-5" />
          <span className="font-medium">ログアウト</span>
        </button>
      </div>
    </div>
  );
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/54f5a96b-00e9-40ff-b073-9d80b61bc59c.png)

ログアウトを押すとuseCLerkにある`signOut`が呼ばれてログアウトされます。

```tsx
  const handleLogout = async () => {
    await signOut();
  };

  (省略)

    <button
          onClick={handleLogout}
          className="w-full text-left px-4 py-3 rounded-full hover:bg-red-100 transition-colors flex items-center gap-3 text-red-600 border border-red-200"
        >
```

# 9. 投稿フォームのスタイリング

こちらもTailwindCSSとlucide-reactでスタイリングしていきます。

```tsx:src/components/PostForm.tsx
"use client";
import { useState, useRef } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/lib/client";
import Image from "next/image";
import { UserProfile } from "@/domain/User";
import { BarChart, Calendar, FileImage, MapPin, Smile, X } from "lucide-react";

export default function PostForm({ user }: { user: UserProfile }) {
  const [content, setContent] = useState("");
  const [image, setImage] = useState<string | null>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);
  const queryClient = useQueryClient();

  const createPostMutation = useMutation({
    mutationFn: async (newPost: {
      content: string;
      handle: string;
      image?: string;
    }) => {
      const res = await client.post.create.$post(newPost);
      return await res.json();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["posts", user.handle] });
      setContent("");
      setImage(null);
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (content.trim()) {
      createPostMutation.mutate({
        content,
        handle: user.handle,
        image: image || undefined,
      });
    }
  };

  const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) {
      const reader = new FileReader();
      reader.onloadend = () => {
        const base64String = reader.result as string;
        setImage(base64String);
      };
      reader.readAsDataURL(file);
    }
  };

  const handleImageButtonClick = () => {
    fileInputRef.current?.click();
  };

  return (
    <form onSubmit={handleSubmit} className="p-4">
      <div className="flex gap-3">
        <div className="flex-shrink-0">
          <div className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center text-white font-bold text-lg shadow-md">
            {user.name[0]?.toUpperCase()}
          </div>
        </div>
        <div className="flex-grow">
          <textarea
            value={content}
            onChange={(e) => setContent(e.target.value)}
            placeholder="What is happening?!"
            className="w-full p-2 text-xl border-none focus:outline-none resize-none min-h-[80px]"
            rows={3}
          />

          {image && (
            <div className="relative mt-2 mb-3">
              <div className="rounded-xl overflow-hidden relative max-h-[300px]">
                <Image
                  src={image}
                  alt="Upload preview"
                  width={500}
                  height={300}
                  className="object-contain max-w-full"
                />
              </div>
              <button
                type="button"
                className="absolute top-2 right-2 bg-gray-800 bg-opacity-70 text-white rounded-full p-1"
              >
                <X size={16} />
              </button>
            </div>
          )}

          <div className="border-t border-gray-100 pt-3 flex justify-between items-center">
            <div className="flex gap-2 text-blue-500">
              <button
                type="button"
                onClick={handleImageButtonClick}
                className="p-2 rounded-full hover:bg-blue-50"
              >
                <FileImage size={18} />
              </button>
              <input
                ref={fileInputRef}
                type="file"
                accept="image/*"
                className="hidden"
                onChange={handleImageSelect}
              />
              <button
                type="button"
                className="p-2 rounded-full hover:bg-blue-50"
              >
                <BarChart size={18} />
              </button>
              <button
                type="button"
                className="p-2 rounded-full hover:bg-blue-50"
              >
                <Smile size={18} />
              </button>
              <button
                type="button"
                className="p-2 rounded-full hover:bg-blue-50"
              >
                <Calendar size={18} />
              </button>
              <button
                type="button"
                className="p-2 rounded-full hover:bg-blue-50"
              >
                <MapPin size={18} />
              </button>
            </div>

            <button
              type="submit"
              className="px-4 py-2 bg-blue-500 text-white rounded-full font-medium hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
            >
              {createPostMutation.isPending ? "Posting..." : "Post"}
            </button>
          </div>
        </div>
      </div>
    </form>
  );
}
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/b8c92b4a-f313-4c33-86c3-0d492a671c97.png)

いい感じになりました。画像をつけてるとよりクローンアプリ感があります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/4c859456-d827-43bf-808f-423d9a359e10.png)

# 10. タイムラインのスタイリング

投稿1つ1つはコンポーネントにして使い回せるようにします。

```
$ touch src/components/PostItem.tsx
```

```tsx:src/components/PostItem.tsx
"use client";

import { Post } from "@/domain/Post";
import { Button } from "@/components/ui/button";
import { MessageCircle, Repeat, Heart, Share } from "lucide-react";
import Image from "next/image";

interface PostItemProps {
  post: Post;
}

export function PostItem({ post }: PostItemProps) {
  return (
    <div className="hover:bg-gray-50 transition-colors px-4 py-3">
      <div className="flex space-x-3">
        <div className="flex-shrink-0">
          <div className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center text-white font-bold text-lg shadow-md">
            {currentUserHandle?.[0]?.toUpperCase()}
          </div>
        </div>
        <div className="flex-1 min-w-0">
          <div className="flex items-center text-sm">
            <span className="font-bold text-gray-900 mr-1">
              {post.name || post.handle}
            </span>
            <span className="text-gray-500 mr-1">@{post.handle}</span>
            <span className="text-gray-500">
              · {new Date(post.createdAt).toLocaleDateString()}
            </span>
          </div>
          <p className="mt-1 text-gray-900">{post.content}</p>
          {post.image && (
            <div className="mt-2 rounded-xl overflow-hidden relative max-h-[300px]">
              <Image
                src={post.image}
                alt="Post image"
                width={500}
                height={300}
                className="object-contain max-w-full"
              />
            </div>
          )}
          <div className="flex justify-between max-w-md mt-3">
            <Button
              variant="ghost"
              size="sm"
              className="h-8 px-0 text-gray-500 hover:text-blue-500"
            >
              <MessageCircle className="h-[18px] w-[18px]" />
              <span className="ml-2 text-xs">0</span>
            </Button>
            <Button
              variant="ghost"
              size="sm"
              className="h-8 px-0 text-gray-500 hover:text-green-500"
            >
              <Repeat className="h-[18px] w-[18px]" />
              <span className="ml-2 text-xs">0</span>
            </Button>
            <Button
              variant="ghost"
              size="sm"
              className="h-8 px-0 text-gray-500 hover:text-red-500"
            >
              <Heart className="h-[18px] w-[18px]" />
              <span className="ml-2 text-xs">{post.like}</span>
            </Button>
            <Button
              variant="ghost"
              size="sm"
              className="h-8 px-0 text-gray-500 hover:text-blue-500"
            >
              <Share className="h-[18px] w-[18px]" />
            </Button>
          </div>
        </div>
      </div>
    </div>
  );
}
```

このコンポーネントを`PostList`で使ってみましょう

```tsx:src/components/PostList.tsx
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/lib/client";
import { Post } from "@/domain/Post";
import { UserProfile } from "@/domain/User";
import { PostItem } from "./PostItem";

export default function PostList({ user }: { user: UserProfile }) {
  const queryClient = useQueryClient();

  const { data: posts, isLoading } = useQuery<Post[]>({
    queryKey: ["posts", user.handle],
    queryFn: async () => {
      const res = await client.post.all.$get();
      const data = await res.json();
      return data as Post[];
    },
  });

  if (isLoading) {
    return <p className="text-center py-4">Loading...</p>;
  }

  if (!posts || posts.length === 0) {
    return (
      <p className="text-center py-4">No posts yet. Be the first to post!</p>
    );
  }

  return (
    <div className="divide-y divide-gray-100">
      {posts.map((post: Post) => (
        <PostItem key={post.id} post={post} />
      ))}
    </div>
  );
}
```

いい感じになったので画像つきの投稿が表示されるかを確認します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/01f94ab0-966c-46a7-b726-3cb9e13bb0e5.png)

すると以下のエラーが出ました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/b5956f99-2708-43a5-9ac3-9c2db4e48bde.png)

Next.jsのnext/imageコンポーネントは、セキュリティ上の理由から、デフォルトでは外部ドメインからの画像の読み込みを制限しています。そこでCloudinaryから読み込みできるように設定を変えましょう

```mjs:next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    domains: ["res.cloudinary.com"],
  },
};

export default nextConfig;
```

これで画像のポストが表示できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/d773f942-46e5-4e9a-96ba-caa01c2cf6ff.png)

ここまでで今回のハンズオンは終了となります。
よりTwitterクローンにするための課題を用意しましたのでここまでの内容を踏まえて開発してみてください

# スキルを定着させる挑戦課題

**1. いいね機能をつける**

いいねをつけられるようにしてください。
同じ投稿にいいねは1つまでしかつけられず、オンオフできるように実装してください

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/c157d904-e7b2-491c-be0c-4c10369e54c8.png)


**2. プロフィール画面の実装**

プロフィール画面を実装してください (/profile)
画像、ハンドルネーム、自己紹介を変更できるようにしてください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/d7a0858d-87b2-4f17-88c9-6bbef81150a5.png)


またタイムラインに自分の投稿と自分がいいねした投稿をタイムラインに表示してください

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/10922f96-6ef8-44bc-8345-766f91db6ca8.png)

**3. ユーザーアイコンの表示**

タイムラインのそれぞれの投稿に投稿したユーザーのアバター画像を使うように修正してください

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/d0a62b87-3e6c-4864-8e21-fe17e9c327b7.png)

# おわりに

今回は次世代スタックであるJStackについて解説しました！
簡単に使える仕組みがあるので従うだけで素早く型安全に開発ができました。

テキストでは細かく解説しましたが、一部説明しきれていない箇所もありますので動画教材で紹介しています。合わせてご利用ください

<iframe width="560" height="315" src="https://www.youtube.com/embed/zqX44IhgKd8?si=hjjC_8OAaZzk7W8m" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

https://github.com/jinwatanabe/jstack-twitter-clone

# JISOUのメンバー募集中！
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか？
興味のある方は、ぜひホームページからお気軽にカウンセリングをお申し込みください！
▼▼▼

https://projisou.jp

# 図解ハンズオンたくさん投稿しています！
https://qiita.com/Sicut_study/items/d4778cbe8b499570f79e

https://qiita.com/Sicut_study/items/1d5c4f2ddac621565ce2

https://qiita.com/Sicut_study/items/ee41f1bb59fcda45a50b

# 本記事のレビュアーの皆様

- Arisa様
- tokec様
- k-kaijima様
- 山本様

次回のハンズオンのレビュアーは[X](https://x.com/Sicut_study)にて募集します。


# 参考

https://jstack.app/