73
56

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-04-06

アートボード 1.png

はじめに

こんにちは、Watanabe Jin (@Sicut_study)です。

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

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

image.png
作者をYoutubeで一度はみたことあるのではないいでしょうか?

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

image.png

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

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

で構成されています。

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

image.png

そんな不満をもったJosh氏が作ったのがJStackでした。

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

名称未設定のデザイン (3).gif

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

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

動画教材も用意しています

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

対象者

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

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

What is JStack?

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

image.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

今回利用する技術

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

image.png

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

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

1. 環境構築

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

$ node -v
v20.8.0

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

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

$ 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にアクセスしましょう

image.png

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

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

$ 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のボタンコンポーネントに変えてみましょう

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

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

2.DBの連携をする

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

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

「New Project」をクリック

image.png

image.png

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

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

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

image.png

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

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

image.png

.env
DATABASE_URL=あなたのURL

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

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

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

image.png

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

image.png

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

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

image.png

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

image.png

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

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

$ npm install @clerk/nextjs

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

image.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
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を設定します。

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を使うことで簡単に認証しているかどうかや、認証しているユーザーの情報を取得できるようになります。

    <ClerkProvider>
      (省略
    </ClerkProvider>

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

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で認証したいページを設定します。

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

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

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

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

export default Home;

image.png

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

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

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

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

image.png

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

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

image.png

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

image.png

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

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

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

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

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

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

image.png

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

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

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

image.png

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

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

image.png

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

image.png

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

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

image.png

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

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

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

$ 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

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

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

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

image.png

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

5. JStackでAPIを開発する

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

$ touch src/server/routers/ping-router.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にルーティングの設定をすることで型安全などの恩恵を受けることができます。

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

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

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

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.mergeRoutersapiの基本設定とルーティングを渡します。

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

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

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

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

  system: pingRouter,

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

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にいれます。

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を作成しましょう。
実装の内容は公式ドキュメントを参考にしていきます。

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

$ npm install svix

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

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

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

$ touch src/server/routers/clerk-webhook-router.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を利用できます。

    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

「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が正しい情報かを検証するところです。

    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に検証のリクエストをしています

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

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

    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を設定します。

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

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

image.png

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

image.png

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

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

image.png

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

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

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

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

$ touch src/components/PostForm.tsx
$ touch src/components/PostList.tsx
$ touch src/components/SideMenu.tsx
PostForm.tsx
import React from "react";

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

export default PostForm;
PorstList.tsx
import React from "react";

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

export default PostList;
src/app/components/SideMenu.tsx
import { SignOutButton } from "@clerk/nextjs";
import React from "react";

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

export default SideMenu;
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;
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

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

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

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)]
);

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

(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

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

「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

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

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を使って作成日順の降順でソートしてレスポンスを返します。

    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

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

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

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

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

image.png

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

image.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
Post.ts
export type Post = {
  id: number;
  content: string;
  like: number;
  handle: string;
  image: string | null;
  createdAt: Date | string;
  avatarUrl: string | null;
  name: string;
};

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

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を利用するためクライアントサイドであることを明示的に指定します。

"use client";

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

  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で実際の取得処理を実装しています。

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

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

image.png

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

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

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

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

image.png

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

$ touch src/components/Provider.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が使えるようにしましょう

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

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

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

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

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

image.png

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

image.png

「Generate New API Key」をクリック

image.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
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というディレクトリに画像を保存しています。

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

APIを作成します。

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が送られていないとエラーになります。

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

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

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

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

      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
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;
};

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

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>
  );
}

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

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

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

  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に作成の処理を書きます。

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

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

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

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

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

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

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

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

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

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

            <input
              ref={fileInputRef}

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

  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に設定した関数が実行されます。

  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
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でバリデーションをかけます

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

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

     const { userId } = input;

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

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

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

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の呼び出し方を変えます。

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を渡しています。

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

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

image.png

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

image.png

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

image.png

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

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

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

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

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

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>
  );
}
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の情報を受け取っていないので受け取るように直しました

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

そしてqueryKeypostsuser.handleの複合キーにしました

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

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

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
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

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

  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でスタイリングしていきます。

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

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

image.png

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

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

$ touch src/components/PostItem.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で使ってみましょう

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

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

image.png

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

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

export default nextConfig;

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

image.png

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

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

1. いいね機能をつける

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

image.png

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

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

image.png

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

image.png

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

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

image.png

おわりに

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

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

図解ハンズオンたくさん投稿しています!

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

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

次回のハンズオンのレビュアーはXにて募集します。

参考

73
56
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
73
56

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?