はじめに
こんにちは、Watanabe Jin (@Sicut_study)です。
突然ですが、皆さんはT3 Stackという言葉をご存知でしょうか?
T3 StackとはTheo氏によって2021年に提唱されたWebアプリケーション開発のための技術スタックです。
![]() |
作者をYoutubeで一度はみたことあるのではないいでしょうか? |
T3 Stackは以下のような思想があるアプリケーションです。
「簡素さ」「モジュール性」「フルスタック安全」を実現できる技術スタックを集めた総称をT3 Stackと呼びます。
- Next.js
- TypeScript
- trpc
- NextAuth.js
- Prisma
- TailwindCSS
で構成されています。
2021年に生まれて多く利用されてきたモダンなスキルスタックと呼ばれていた構成でしたが現代いくつかの不満が生まれてきました。
そんな不満をもったJosh氏が作ったのがJStackでした。
今回は「JStack」を使ってX(旧:Twitter)のクローンアプリを開発していきます。
JStackを使うことでエンドツーエンドの型安全を体験しながら、Clerkを組み合わせてよりT3 Stackに近い開発ができるようにチュートリアルを作成しました。
このチュートリアルをやることでモダンな技術スタックをまとめて手を動かして学ぶことできます!
動画教材も用意しています
こちらの教材にはより詳しく解説した動画もセットでご用意しています。
テキスト教材でわからない細かい箇所があれば動画も活用ください
対象者
- Next.jsを学んでみたい人
- Reactを少し学んだことがある人
- モダンなスキルを全体的に学びたい人
- ログイン認証を実装してみたい人
- アプリ開発を通して学びたい人
Reactの基本的な仕組みを理解している人であれば2時間程度で学習することが可能です
What is JStack?
JStackはJosh氏が開発したTypeScriptとNext.jsをベースにしたフルスタック開発ツールキットです。
JStackは、T3 Stackの制限を解消し、開発者体験とアプリケーションパフォーマンスを向上させるために作成されました。
スキルスタックは以下で構成されています。
-
TypeScript
TypeScriptを利用することでフロントエンドからバックエンドまでの一貫した型安全性を提供し、開発段階でのエラーを減らします。
-
Next.js
Reactをベースにしたフルスタックフレームワークです。高性能なWebアプリケーションを構築できます。
-
Hono
軽量でポータブルなバックエンドフレームワークです。Honoを使用してWeb標準のレスポンスをネイティブにサポートし、JSON以外のレスポンス形式も扱えるます。
-
Drizzle
TypeScriptで書かれた軽量で柔軟なORMです。
Drizzleを使用してデータベース操作を型安全に行い、SQLのようなクエリAPIも提供します。
-
Zod
ランタイムでのデータ検証を提供するライブラリです。
Zodを使用してデータの整合性を保ち、TypeScriptとの連携でさらに安全なデータ処理を実現します。
これらの技術を組み合わせることで、JStackは開発者に高速で軽量な、かつエンドツーエンドで型安全な開発環境を提供します。
T3 Stackの作者であるTheo氏も絶賛しております。
今回利用する技術
今回はJStackの他にも以下の技術を利用することでモダンなアプリケーションを構築していきます。
認証で人気のサービスである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にアクセスしましょう
この画面が表示されれば大丈夫です。
「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のボタンコンポーネントに変えてみましょう
"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>
)
}
サーバーを再起動してアクセスをすると、ボタンが正しく表示されているので導入がうまくいっていることが確認できました。
2.DBの連携をする
ここからは「Neon」と「Drizzle」を使ってDBの初期設定をしていきます。
いまアプリにあるポストの作成と最新ポストの表示を目指します。
まずはNeonでプロジェクトを作成します。アカウントがない方は作成してログインしてください。
「New Project」をクリック
- Project name : twitter-clone-app
- Database name : mydb
これらを入力して「Create」をクリック
この時点ではテーブルやスキーマなどはないので、コードで書いて反映をさせます。
ここで利用できるのがDrizzle ORMです。
このプロジェクトではsrc/server/db/schema.ts
にテーブル定義のサンプルが書いてあります。
これをDBに反映させましょう。
まずは.env
にDB_URLを追加します。
Neonを開いて「Connect」を押して「Connect String」をコピーして貼り付けます。
DATABASE_URL=あなたのURL
スキーマとテーブルをDBに反映させるコマンドは事前に用意されています。
{
"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」を選ぶとテーブルが自動で作成されたことがわかります。
これでDBの連携はできたので実際にアプリケーションのフォームに入力して「Create Post」をクリックします。
入力したものが表示されています。DBをみてもデータが追加されていることがわかります。
3. ログインできるようにしよう
続いてClerkを使ってログインできるようにします。
アカウントがない人は作成をしてから次に進んでください。
「Create appplication」をクリック (横にあるプロジェクトは無視してください)
「Application name」にtwitter-clone-appと入力して「Create application」をクリック
ここからは初期設定を手順通りに行います。
$ npm install @clerk/nextjs
手順2のAPIキーをすべてコピーして.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
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を設定します。
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のタイムラインとするので認証していないとみれない(認証してない場合はログイン画面にリダイレクト)ように設定します。
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(["/"]);
/
に対応するページを作成します。
import React from "react";
function Home() {
return <div>Home</div>;
}
export default Home;
clerkMiddleware
の中ではもしパスがマッチするなら認証を確かめる設定をしています
export default clerkMiddleware(async (auth, req) => {
if (isProtectedRoute(req)) {
await auth.protect();
}
});
auth.protect()をすることで認証済み以外のユーザーはアクセスできなくなります。
実際に確かめてみます。
http://localhost:3000にアクセスすると認証画面にリダイレクトされます。
実際にアカウントを作成します。今回はGoogleでログインしてみます。
「Sign up」から「Continue With Google」をクリック(リダイレクト先はSign inなので注意)
「Verfify You are Human」チェック入れてアカウントを選択して「次へ」をクリック
ログインができると/
にリダイレクトされてHomeがみれました
このままだとログインしたままになって検証がしづらいのでログアウトボタンをつけてみます。
import { SignOutButton } from "@clerk/nextjs";
import React from "react";
function Home() {
return (
<div>
<h1>Home</h1>
<SignOutButton />
</div>
);
}
export default Home;
「Sigin Out」をクリックするとログアウトできます。
Clerkには便利なコンポーネントがあり、<SignOutButton>
はログアウトを実装できるものです。
4.ユーザー情報をDBに保存する
ここからはClerkのWebHookという機能を使ってClerkで認証をしたらDBにもユーザーデータを保存する仕組みを実装します。
まずは今回実装するWebHookの仕組みから解説していきます。
ClerkにはWebHookという仕組みがあり、「Sign UP」(ユーザー作成)が行われたら、/api/webhook/clerk
を叩くように設定を行うことができます。
/api/clerk/webhook
には作成されたユーザーの情報を渡します。
そして/api/clerk/webhook
のAPIの中でDBにユーザー情報を保存する処理が動いて保存が完了します。
実際にこの流れで設定を行っていきます。まずはClerkを開いてください
「Configure」から「Webhooks」を開いて「Add Endpoint」をクリック
Endpoint URLには実際にClerkが叩くエンドポイント
Subscribe to eventにはどの操作が行われたときにエンドポイントを叩くかを設定します。
Endpoint URL : http://localhost:3000/api/webhook/clerk
Subscribe to event : user.created
「Create」を押すとWebHookが作成できるはずですがエラーになります。
localhost:3000
としてしまうとClerk側のホストのlocalhost:3000
という意味になってしまい私たちのアプリのAPIを叩けません。つまりアプリをデプロイしてあげる必要があります。
今回はデプロイすると開発が止まってしまうためngrok
を使ってポートフォワードをします。ngrokの解説をしていきます。
ポートフォワードという仕組みを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でアクセスしてみるとログイン画面が表示されます。
※ ただし実際にログインはClerkの関係で使えないので注意
それではこのURLをWebHookとして設定しましょう
EndPoint URL : [あなたのURL]/api/webhook/clerk
「Create」をおして設定は完了です。ngrokを切るとURLが変わるので切らないようにしてください。
5. JStackでAPIを開発する
ここからはJStackの機能の一つである型セーフなAPI開発を行っていきます。
JStackではsrc/server
にAPIに関するファイルを作成します。
まずは試しに簡単なAPIを作ってみましょう
$ touch src/server/routers/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を実装したのでこれを使えるように設定してきます。
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
の基本設定とルーティングを渡します。
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にいれます。
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に反映させます。
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 |
メールアドレス | |
name | 名前 |
handle | ハンドルネーム(@から始まるやつ) |
avatarUrl | アバター画像のURL |
bio | 自己紹介文章 |
createdAt | 作成日 |
updatedAt | 更新日 |
それではテーブルの型情報の出力とNeonへの更新をしましょう
$ npm run db:generate
$ npm run db:push
Neonをみてテーブルができていれば成功です。ClerkでSign upしたらここにユーザーレコードが作成されることを目指してAPIを開発しましょう
次にAPIを実装するためのファイルをつくります。
$ touch src/server/routers/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に設定しましょう
「Signing Secert」からコピーして.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を設定します。
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のダッシュボードを開きます。
「Testing」を選択してSend Eventをuser.created
にします。
「Send Example」を送信してSuccessed
になれば問題ないです。エラーが出る場合はコンソールのログをみて対応してください。
それでは実際にClerkを使ってログインをしてDBにユーザー情報が保存できるかを確かめます。
先程Googleログインしたアカウントは削除します。Clerkの「User」を開いてください
三点リーダーから「Delete」を押して「Delte」を選択すると削除できます。
同じユーザーはsing upができないので削除を行っています。
それでは実際にhttp://localhost:3000からログインをしてみます。
Neonをみるとユーザーテーブルにデータが追加されていることがわかります。
6. タイムライン画面を作成する
次にタイムライン画面を実装していきます。
この画面はコンポーネントとして「サイドメニュー」「ポスト投稿フォーム」「投稿を表示するタイムライン」の3つにコンポーネントをわけて実装します。
サイドメニューはすべてで共通なのでレイアウトに追加します。
$ touch src/components/PostForm.tsx
$ touch src/components/PostList.tsx
$ touch src/components/SideMenu.tsx
import React from "react";
function PostForm() {
return <div>PostForm</div>;
}
export default PostForm;
import React from "react";
function PostList() {
return <div>PostList</div>;
}
export default PostList;
import { SignOutButton } from "@clerk/nextjs";
import React from "react";
function SideMenu() {
return (
<div>
<SignOutButton />
</div>
);
}
export default SideMenu;
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;
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>
);
}
レイアウトだけなのでまずは投稿を表示する機能だけ作成します。
posts
テーブルを作成しましょう。実はもうすでにデフォルトで用意されているので修正をしてDBに反映していきます。
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
ここも同じく変更を一つずつ聞かれます。そのあとにレコードあるけど消していい?と聞かれるので消しましょう。
テーブルのスキーマが違うレコードがあるとおかしくなるため削除しています。
最初に作成したレコードは全て消えていて、カラムも新しくなっていることがわかります。
次にテストデータをいくつか追加しましょう。
「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です。
それでは実際にこのレコードをJStackを使って表示しましょう
まずはAPIから作成していきます。
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でテーブルの情報を型に出力して利用しているからです)
それでは実際に叩いてみましょう
$ curl localhost:3000/api/post/all
{"json":[]}
なぜかデータが帰ってきません。これはユーザーとポストをhandle
で結びつけられていないからです。
とりあえずpostsのhanldeをすべてユーザーのhandleに更新しましょう(人それぞれ異なります)
handleはログインしているメールアドレスの@以前が設定されています
私の場合はhandle
がjin.watanabe.6g
なのでこの値をpostsの3つのデータのhandleに入れて更新します。
もう一度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
export type Post = {
id: number;
content: string;
like: number;
handle: string;
image: string | null;
createdAt: Date | string;
avatarUrl: string | null;
name: string;
};
私たちがこのアプリで扱う投稿をデータ型で表現しました。
"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
にホバーをすると正しく型情報が表示されます。
これでフロントエンドからバックエンドまで型安全に開発が可能です。
dataはPost[]になっているのですが最後にas Post[]
をつけないとuseQueryで怒られるのでいれておきます。
ローディング状態またはpostsがないときはローディング表示を出しておきます。
if (isLoading || !posts) {
return <p>Loading...</p>;
}
それでは実際に画面を確認しましょう
QuryClientProviderを設定しろと怒られているので設定します。
$ touch 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が使えるようにしましょう
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>
);
}
投稿のデータが表示されるようになりました。
JStackを使うことで簡単にAPIとの連携ができることがわかります。
7. 新しいポストを投稿しよう
次にポストを作成できる画面を実装します。
ここではポストの投稿のあとにCloudinaryを利用した画像保存についても学びます。
まずはCloudinaryのアカウントを作成してください。
「Go to API Keys」(またはView API Keys)をクリック
「Generate New API Key」をクリック
作成できたら「Cloud name」と「API Key」と「API Secret」の値をコピーして.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
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を作成します。
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
export type UserProfile = {
id: number;
clerkId: string;
email: string;
name: string;
handle: string;
avatarUrl: string | null;
bio: string | null;
createdAt: string;
updatedAt: string;
};
次にフォームを作ります。
"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);
},
});
ここでのポイントはqueryKey
にposts
を選択していることです。
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
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を作ったので登録をします。
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の呼び出し方を変えます。
"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 });
それでは実際に投稿してみましょう
わかりずらいですが、クリックするとフォームが現れます。
「画像ファイルを選択」を押すと画像も選択できます
「投稿する」をクリックするとタイムラインに投稿が追加されます(画像をアップロードするので時間がかかります)
問題なく投稿できてタイムラインも更新されました。
いまonSuccessがこのようになっています
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
setContent("");
setImage(null);
},
queryKey
をposts
にしている箇所が再取得されるのですがこのままだと誰かが投稿すると全ユーザーのタイムラインが更新されてしまいます。投稿は1秒に何件も行われるのが通常なので取得が連続で行われてしまいます。
そこでユーザーの認証情報を追加して投稿したユーザーだけが再取得されるようにしましょう
"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>
);
}
"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 }) {
そしてqueryKey
をposts
とuser.handle
の複合キーにしました
queryKey: ["posts", user.handle],
最後にPostListにユーザーを渡すように直します。
"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
"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>
);
}
ログアウトを押すと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でスタイリングしていきます。
"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>
);
}
いい感じになりました。画像をつけてるとよりクローンアプリ感があります。
10. タイムラインのスタイリング
投稿1つ1つはコンポーネントにして使い回せるようにします。
$ touch 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
で使ってみましょう
"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>
);
}
いい感じになったので画像つきの投稿が表示されるかを確認します。
すると以下のエラーが出ました。
Next.jsのnext/imageコンポーネントは、セキュリティ上の理由から、デフォルトでは外部ドメインからの画像の読み込みを制限しています。そこでCloudinaryから読み込みできるように設定を変えましょう
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ["res.cloudinary.com"],
},
};
export default nextConfig;
これで画像のポストが表示できました。
ここまでで今回のハンズオンは終了となります。
よりTwitterクローンにするための課題を用意しましたのでここまでの内容を踏まえて開発してみてください
スキルを定着させる挑戦課題
1. いいね機能をつける
いいねをつけられるようにしてください。
同じ投稿にいいねは1つまでしかつけられず、オンオフできるように実装してください
2. プロフィール画面の実装
プロフィール画面を実装してください (/profile)
画像、ハンドルネーム、自己紹介を変更できるようにしてください。
またタイムラインに自分の投稿と自分がいいねした投稿をタイムラインに表示してください
3. ユーザーアイコンの表示
タイムラインのそれぞれの投稿に投稿したユーザーのアバター画像を使うように修正してください
おわりに
今回は次世代スタックであるJStackについて解説しました!
簡単に使える仕組みがあるので従うだけで素早く型安全に開発ができました。
テキストでは細かく解説しましたが、一部説明しきれていない箇所もありますので動画教材で紹介しています。合わせてご利用ください
図解ハンズオンたくさん投稿しています!
本記事のレビュアーの皆様
- Arisa様
- tokec様
- k-kaijima様
- 山本様
次回のハンズオンのレビュアーはXにて募集します。
参考