寄り道おみくじを Next.js でつくった話──Figma/Next.js/GitHub/Vercel

リソース/ツール

──日付ID・型設計・Vercelデプロイまで──

寄り道おみくじ
今日の気分で、ちょっと寄り道して引くライトなおみくじアプリ。

本ページでは、Next.js と TypeScript を用いて「フロントエンドのみで完結する日替わり占いアプリ」 を実装する際の技術的なポイントをまとめています。

扱う主なテーマは次の4つです。

  • 日付と入力条件から結果を固定する「決定論的ランダム」のロジック
  • TypeScript による占いデータの型設計
  • Next.js クライアントコンポーネントで完結させる構成
  • 実装時に意識した設計上の工夫(タイムゾーン・ハッシュ・OGP など)

最後に、「寄り道おみくじ」というプロダクトの性質に合わせて、どのような設計判断をしたかも簡単に触れます。


日替わり占いで満たしたい挙動

今回の占いアプリでは、次のような挙動を満たすことを前提にしました。

  • 同じ日 × 同じ星座 → 同じ結果を返す
  • 日付が変わると → 結果が変わる可能性がある
  • バックエンドや DB は用意せず、フロントエンドのみで完結させる
  • 占いの中身(テキストやラッキーアイテム)は配列で管理し、後から増減しやすくする

Math.random() をそのまま使ってしまうと、ページを開き直すたびに結果が変わり、「同じ日なのに毎回違う結果になる」状態になります。
そのため本アプリでは、「今日」と「星座」から結果を計算して固定する方針にしています。

ここでは

「決定論的に計算されるが、見た目はランダムに振る舞うインデックス」

として実装しています。


TypeScriptによる占いデータの型設計

先に「何を扱うか」の型から固めておきます。
今回は星座占いを題材にしているため、最低限つぎのような要素を持たせています。

  • 星座(12種類)
  • 運勢レベル(大吉・中吉・小吉・凶などのイメージ)
  • メッセージ本文
  • ラッキーアイテム名

星座と運勢レベルの型

// domain/fortune.ts
export const CONSTELLATIONS = [
  'おひつじ座',
  'おうし座',
  'ふたご座',
  'かに座',
  'しし座',
  'おとめ座',
  'てんびん座',
  'さそり座',
  'いて座',
  'やぎ座',
  'みずがめ座',
  'うお座',
] as const;

export type Constellation = (typeof CONSTELLATIONS)[number];

export type FortuneLevel = 'great' | 'good' | 'neutral' | 'bad';

as const によって、Constellation はリテラル型の共用体になります。
存在しない星座名を扱おうとすると、その時点で TypeScript が警告してくれます。

メッセージとラッキーアイテム

export type FortuneMessage = {
  id: string;
  level: FortuneLevel;
  text: string;
};

export type LuckyItem = {
  id: string;
  name: string;
};

export const MESSAGES: FortuneMessage[] = [
  {
    id: 'msg_001',
    level: 'good',
    text:
      '今日は、小さな「やらないこと」を一つ決めてみると、気分が軽くなりやすい日です。',
  },
  // 必要に応じて追加
];

export const LUCKY_ITEMS: LuckyItem[] = [
  { id: 'item_001', name: 'いつもと違う飲み物' },
  { id: 'item_002', name: '小さなメモ帳' },
  // 必要に応じて追加
];

このようにしておくことで、

  • level に定義外の値を書くとコンパイルエラーになる
  • メッセージ構造が明示されるため、UI 側とのズレが起きにくくなる
  • 将来的に JSON に切り出したときも、同じスキーマで検証しやすい

といったメリットがあります。


日付×星座からインデックスを生成するロジック

ここからが本題の「決定論的ランダム」です。
日替わりにするために、まず「今日」という条件を 日付ID に変換して扱います。

※日付IDは、乱数でいう seed(入力値)に近い役割です。以降は「日付ID」と呼びます。

日付IDを作る(ローカルタイム版)

// lib/seed.ts
const getDateSeedLocal = (date: Date): number => {
  const y = date.getFullYear();
  const m = date.getMonth() + 1; // 0始まりなので +1
  const d = date.getDate();

  // YYYYMMDD のような整数にする
  return y * 10000 + m * 100 + d;
};

この関数は「ユーザーのローカルタイム」に基づく日付をIDとして使います。
ユーザーごとにタイムゾーンが違ってもよい、という前提であればこれで十分です

日本時間固定で扱いたい場合(日付IDをJST基準にする)

// lib/seed.ts
const getJstDateSeed = (date: Date): number => {
  const utcMs = date.getTime() + date.getTimezoneOffset() * 60_000;
  const jst = new Date(utcMs + 9 * 60 * 60_000); // UTC+9

  const y = jst.getFullYear();
  const m = jst.getMonth() + 1;
  const d = jst.getDate();

  return y * 10000 + m * 100 + d;
};
  • ブラウザのタイムゾーン設定に関わらず、常に「日本時間の 0:00」を境に日付が変わる
  • 日本国内向けのサービスであれば、このほうがユーザー体感とズレにくいです

日付IDを簡易ハッシュで散らす

日付IDをそのまま mod してもよいのですが、連番に近い値が続くと偏りが出やすくなります。
そこで、軽量なビット演算ベースの「簡易ハッシュ」を挟みます。

// lib/seed.ts
const hash = (value: number): number => {
  let x = value >>> 0;
  x ^= x << 13;
  x ^= x >>> 17;
  x ^= x << 5;
  return x >>> 0;
};

ここで使っているのは、擬似乱数生成器(PRNG)でよく使われるXorshift 系の簡略パターンです。暗号学的な安全性はありませんが、日替わりコンテンツ用途としては十分な散らばりが得られます。

日付ID+星座 → 配列インデックス

// lib/seed.ts
export const getDeterministicIndex = (
  date: Date,
  constellationIndex: number,
  modulo: number,
  offset = 0
): number => {
  const dateId = getDateIdJst(date); // もしくは getDateIdLocal
  const mixed = hash(dateId * 31 + (constellationIndex + offset) * 17);
  return mixed % modulo;
};

ポイントは次のとおりです。

  • 3117 は素数で、連番を混ぜるときの定番パターンとして採用しています
  • offset を変えることで、同じ日・同じ星座でも「メッセージ用」「アイテム用」で違う系列のインデックスを作れます

例として、

  • メッセージ → offset = 0
  • ラッキーアイテム → offset = 101

のようにずらすと、同じインデックスが両方に出るパターンを避けやすくなります。


Next.jsクライアントコンポーネントでの実装

アプリは SSR や外部 API を必要としないため、クライアントコンポーネント1つで完結させています。

UI実装例

// app/page.tsx
'use client';

import { useState } from 'react';
import {
  CONSTELLATIONS,
  Constellation,
  MESSAGES,
  LUCKY_ITEMS,
} from '@/domain/fortune';
import { getDeterministicIndex } from '@/lib/seed';

export default function FortunePage() {
  const [selected, setSelected] = useState<Constellation | ''>('');
  const [message, setMessage] = useState<string | null>(null);
  const [item, setItem] = useState<string | null>(null);

  const handleFortune = () => {
    if (!selected) return;

    const constellationIndex = CONSTELLATIONS.indexOf(selected);
    if (constellationIndex < 0) return;

    const today = new Date();

    const msgIndex = getDeterministicIndex(
      today,
      constellationIndex,
      MESSAGES.length,
      0
    );
    const itemIndex = getDeterministicIndex(
      today,
      constellationIndex,
      LUCKY_ITEMS.length,
      101
    );

    setMessage(MESSAGES[msgIndex].text);
    setItem(LUCKY_ITEMS[itemIndex].name);
  };

  return (
    <main className="min-h-screen flex flex-col items-center justify-center px-4">
      <h1 className="text-xl font-bold mb-4">寄り道おみくじ</h1>

      <div className="mb-4">
        <select
          className="border rounded px-3 py-2"
          value={selected}
          onChange={(e) =>
            setSelected(e.target.value as Constellation | '')
          }
        >
          <option value="">星座を選んでください</option>
          {CONSTELLATIONS.map((c) => (
            <option key={c} value={c}>
              {c}
            </option>
          ))}
        </select>
      </div>

      <button
        onClick={handleFortune}
        className="px-4 py-2 border rounded"
      >
        占う
      </button>

      {message && item && (
        <div className="mt-6 border rounded p-4 max-w-md w-full">
          <h2 className="font-semibold mb-2">今日のメッセージ</h2>
          <p className="mb-4 text-sm">{message}</p>
          <p className="text-xs text-gray-600">
            ラッキーアイテム:{item}
          </p>
        </div>
      )}
    </main>
  );
}

ここで意識しているのは次の2点です。

  • 状態管理は useState のみで完結させる(小規模アプリなので十分)
  • 「日付×星座 → インデックス計算」は lib/ に切り出し、UI から見えるのは
    「インデックスが返ってくる関数」と「配列」だけ にする

「寄り道おみくじ」ならではの設計ポイント

ここからは、単なる技術サンプルではなく、プロダクトとしての性質に合わせて調整した点を整理します。

当たり外れより「負荷の低い問いかけ」に寄せる

本アプリでは、以下のような方針でメッセージを設計しています。

  • 「大当たり・大ハズレ」のような極端な結果は付けない
  • 人格を断定する表現は避ける(「あなたは〜な人だ」など)
  • 読んだときに「少し立ち止まる」くらいの温度感にする

技術的には、

  • どのインデックスを引いても、一定以上の「ポジティブ要素」を満たすデータだけを配列に置く

という形で担保しています。

星座ごとの「重み」を付けない

星座ごとに出やすい運勢レベルやメッセージ群を変える設計も可能ですが、今回は実装していません。

  • 「寄り道」の名のとおり、軽く使ってすぐ離脱してよいツールにしたい
  • 星座ごとのバランス調整を始めると、設計と検証コストが一気に増える

という判断です。

拡張するなら、「星座×運勢レベルのテーブル」を用意して、前段でレベル帯を決める構成が考えられます。


よくある失敗パターンと注意点

  • Math.random() だけで決めると、リロードや再訪問で結果が変わります
  • 日付だけを入力にすると、同じ日で全ユーザー同じ結果になります(星座などの入力条件を必ず混ぜます)
  • 配列要素数の増減で結果が変わることがあります(整合性が重要ならバージョン管理を検討します)

補足:Figma / GitHub / Vercel / OGP 周り

Figma生成コードの扱い方

Figma生成コードは「見た目のデザインベース」として必要な部分だけを採用し、使っていない ui/ 配下のファイルは削除しました。
ドメインロジック(型・日替わり計算)は domain/lib/ に集約し、UIと分離しています。

GitHubリポジトリと Vercel デプロイ

小規模アプリのため、ブランチは main のみに統一し、不要ファイルを削ってから Vercel に接続しています。
これでビルド~デプロイまでが自動化されます。

OGP 設定と「静的 or 動的」問題

今回は静的OGPで割り切っています。

// app/layout.tsx
export const metadata = {
  title: '寄り道おみくじ',
  description: '星座を選んで、今日のひと言メッセージを受け取れるミニアプリ。',
  openGraph: {
    title: '寄り道おみくじ',
    description: '今日の星座とひと言メッセージ',
    images: ['/og-yorimichi.jpg'],
  },
};
  • note等ではこのOGPがそのまま使われることが多いです
  • WordPress(テーマによっては)ブログカード側がOGPを生成することがあり、その場合はURLの貼り方やテーマ設定の影響を受けます
  • 結果に応じてOGPを変えたい場合は、@vercel/og を使った動的OGP生成も選択肢になります

まとめ

本記事では、Next.js と TypeScript を用いて

  • 日付×星座から「決定論的ランダム」で結果を決めるロジック
  • 占いデータを型安全に管理する設計
  • クライアントコンポーネント1ページで完結させる構成
  • 「寄り道おみくじ」という軽量なツールに合わせた設計上の判断

を整理しました。

この考え方は占いに限らず、日替わりメッセージ/診断コンテンツなどにも流用できます。


ご案内

👇実装した【寄り道おみくじ】をお試しください。

寄り道おみくじ
今日の気分で、ちょっと寄り道して引くライトなおみくじアプリ。

コメント