5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React でデジタル名刺アプリを作成

Last updated at Posted at 2025-09-09

この記事では、自分の学習ログも兼ねて「デジタル名刺アプリ」を作ったときの流れを書きます。

GitHub Actions のクーロンジョブで毎朝6時にデータを削除するバッチ も実装したので、そのあたりも紹介します。

完成イメージ

  • 自分で決めた ID を入力すると名刺ページを表示できる
  • 名刺の内容は Supabase の DB に保存される
  • 登録したユーザー情報は翌朝 6 時に自動削除される

2025-09-0323.30.34-ezgif.com-video-to-gif-converter.gif

使った技術

  • フロントエンド: Vite + React (TypeScript)
  • バックエンド: Supabase(PostgreSQL/認証)
  • デプロイ: Firebase Hosting
  • バリデーション:  React Hook Form
  • テスト: Jest, React Testing Library
  • CI/CD: GitHub Actions
  • パッケージ管理: npm
  • その他: Makefile(手動デプロイ補助)

1. Supabase にテーブルを作成

まず Supabase の DB に以下のテーブルを作りました。

users テーブル

Name Type Default   Value
id varchar NULL
name varchar NULL
description text NULL
github_id varchar NULL
qiita_id varchar NULL
x_id varchar NULL
created_at timestamptz now()

user_skill テーブル

ユーザー ID とスキル ID を結ぶ中間テーブル

Name Type Default   Value
id varchar NULL
user_id varchar NULL
skill_id int8 NULL
created_at timestamptz now()

skills テーブル

プログラミングの技術を保存しておくテーブル

Name Type Default   Value
id int8 NULL
name varchar NULL
created_at timestamptz now()

2. 名刺登録ページを作る

React + Chakra UI でフォームを作ります。
React Hook Form を使って必須項目のバリデーションも追加しました。

        <FormControl isInvalid={!!errors.userId}>
          <FormLabel>ID(この ID で名刺ページにアクセスできます)</FormLabel>
          <Input
            placeholder="半角英数字・ハイフン・アンダースコアが使用可"
            _placeholder={{ fontSize: "sm" }}
            {...register("userId", {
              required: "IDは必須です",
              pattern: {
                value: /^[A-Za-z0-9_-]+$/,
                message:
                  "ID は半角英数字・ハイフン・アンダースコアのみ使用できます",
              },
              minLength: { value: 3, message: "3文字以上で入力してください" },
              maxLength: { value: 32, message: "32文字以内で入力してください" },
            })}
          />
          <FormErrorMessage>{errors.userId?.message}</FormErrorMessage>
        </FormControl>
  • ID は半角英数字とハイフン、アンダースコア
  • 名前は必須
  • 技術は最低 1 つ選ばないとエラー

登録するとusersuser_skillにデータを保存します。
登録成功後は/cards/:idに遷移して名刺ページを表示します。

3. 名刺ページを作る

ユーザー情報とスキルを取得して、カードっぽく表示します。

        {/* 好きな技術 */}
        {user?.skills?.length ? (
          <Box>
            <Heading size="sm" mb={2} color="brand.500">
              好きな技術
            </Heading>
            <Divider mb={3} borderColor="brand.200" />

            <HStack wrap="wrap" spacing={2}>
              {user.skills.map((s) => (
                <Tag key={s.id} size="sm" variant="soft" px={3} py={1}>
                  {s.name}
                </Tag>
              ))}
            </HStack>
          </Box>
        ) : null}

        {/* Links */}
        {(user?.github_id || user?.qiita_id || user?.x_id) && (
          <Box>
            <Heading size="sm" mb={1} color="brand.500">
              Links
            </Heading>
            <Divider mb={2} borderColor="brand.200" />

            <HStack spacing={4}>
              {user.github_id && (
                <Tooltip label="GitHub">
                  <IconButton
                    as="a"
                    href={user.github_id}
                    target="_blank"
                    rel="noopener noreferrer"
                    aria-label="GitHub"
                    icon={<FaGithub />}
                    variant="ghost"
                    fontSize="24px"
                  />
                </Tooltip>
              )}
              {user.qiita_id && (
                <Tooltip label="Qiita">
                  <IconButton
                    as="a"
                    href={user.qiita_id}
                    target="_blank"
                    rel="noopener noreferrer"
                    aria-label="Qiita"
                    icon={<SiQiita />}
                    variant="ghost"
                    fontSize="24px"
                  />
                </Tooltip>
              )}
              {user.x_id && (
                <Tooltip label="X (Twitter)">
                  <IconButton
                    as="a"
                    href={user.x_id}
                    target="_blank"
                    rel="noopener noreferrer"
                    aria-label="X (Twitter)"
                    icon={<FaXTwitter />}
                    variant="ghost"
                    fontSize="24px"
                  />
                </Tooltip>
              )}
            </HStack>
          </Box>
        )}

SNS(GitHub / Qiita / X)はアイコンリンクを横並びで表示するようにしました。
登録してない場合は非表示になります。

Home ページを作る

トップページから ID を入力して名刺ページに遷移できるようにしました。

  • ID が未入力 -> 「IDを入力してください」とエラー

  • 存在しない ID ->「このIDの名刺は存在しません」とエラー

  • 「名刺を登録する」リンクから /cards/register に遷移できる

5. テストを書く

安心してデプロイできるようにページごとにテストを用意しました。

  • CardDetailPage.test.tsx
    名前・自己紹介・スキル・SNSアイコンが表示されるか

  • CardRegisterPage.test.tsx
    必須項目のバリデーション
    登録成功後に /cards/:id に遷移するか

  • HomePage.test.tsx
    ID未入力や存在しないIDでエラーが出るか
    「名刺を登録する」リンクで遷移できるか

Supabase はモック化して本番のDBに影響しないようにしました。

6. 毎朝6時にデータを削除するバッチ処理

batch/index.tsを作成

import { createClient } from "@supabase/supabase-js";

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

async function main() {
  const now = new Date();
  const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);

  console.info(
    `[INFO] Deleting records created between ${yesterday.toISOString()} ~ ${now.toISOString()}`
  );

  const { data: users, error } = await supabase
    .from("users")
    .select("id")
    .gte("created_at", yesterday.toISOString())
    .lte("created_at", now.toISOString());

  if (error) {
    console.error("[ERROR] users select:", error);
    return;
  }

  if (!users?.length) {
    console.info("[INFO] Target users: 0");
    return;
  }

  const ids = users.map((u) => u.id);

  await supabase.from("user_skill").delete().in("user_id", ids);
  await supabase.from("users").delete().in("id", ids);

  console.log("[SUCCESS] Cleanup completed.");
}

main();

ここではservice_role keyを使って削除しています。

  1. GitHub Actions (cleanup.yml)
name: Cleanup Batch

on:
  schedule:
    - cron: "0 21 * * *" # JST 6時 = UTC 21時
  workflow_dispatch:

jobs:
  cleanup:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install dependencies
        run: npm ci

      - name: Run cleanup batch
        env:
          SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
          SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
          TZ: Asia/Tokyo
          TS_NODE_PROJECT: tsconfig.batch.json
          TS_NODE_COMPILER_OPTIONS: '{"module":"NodeNext","moduleResolution":"NodeNext"}'
        run: node --loader ts-node/esm ./batch/index.ts
  • cron: "0 21 * * *" -> UTC 21時に実行(日本時間の朝6時)

  • 手動実行(workflow_dispatch)も可能

  • .env の値は GitHub Secrets に設定

学んだこと

  • Supabase はやっぱり使いやすい
  • Chakra UI で UI を作るとレスポンシブ対応も楽
  • React Hook Form のバリデーションは useState を消せるので見通しが良くなる
  • GitHub Actions でバッチ処理を組むと定期実行できる

おわりに

同じように「Supabase + GitHub Actionsで定期バッチを動かしたい」という人の参考になれば嬉しいです

JISOUのメンバー募集中!

プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてください!
▼▼▼

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?