🛠️

大学生が数式を入力できる数学Q&Aサイトを作った話 (技術編)

2025/03/10に公開

はじめに

この記事は大学生が数式を入力できる数学Q&Aサイトを作った話 (機能編)の続編です。
サイトの紹介や機能については機能編に詳しく書いているのでぜひ見ていただけると嬉しいです!
機能編からかなり時間が空いてしまいましたが、技術スタックやアーキテクチャ、デプロイについて詳しく説明していきます。
独学で学習したため間違っている部分や問題のある実装があるかもしれませんが、指摘していただけるとありがたいです。
https://www.ui-math.site

技術スタック

まず、技術スタックは以下のようになっています。

  • Next.js (App Router)
  • TypeScript
  • Tailwind CSS
  • Supabase

それぞれの技術について詳しく説明していきます。

Next.js (App Router)

フロントエンドはNext.jsのApp Routerを採用しました。
Next.jsを採用したのは、Javascriptの学習をする中で様々なWebサイトでReactが使われていることを知り、実際に使ってみたかったからです。

しかし、このサイトを作るまでHTML/CSS/JavaScriptやRailsを少し触ったことがある程度だったので、React, Next.js, TypeScriptを理解するまでかなり時間がかかりました。
特にこのサイトを作り始めた時はApp Routerの情報が少なく、クライアントコンポーネントとサーバーコンポーネントの意味を理解するまでが大変でした。

学習は、React, Next.js, TypeScriptをドットインストールで学習し、Todoアプリなどを作りながら基礎を学んだ後にNext.js公式のチュートリアルをやってみました。
公式のチュートリアルは、かなり細かい部分まで説明されており、Webアプリに必要な機能や要素が網羅的に理解できたのでサイトを作る上でとても参考になりました。

結果的にNext.jsを採用して、ReactやNext.jsの開発体験の良さを実感することができ、最近のWebアプリで広く採用されている理由を理解することができました。

TypeScript

開発言語はTypeScriptを使用しています。
学習し始めたときは、型を定義するのが面倒だったり、エラーが多くなったりしてTypeScriptを使うメリットが分かりませんでしたが、コード量が多くなっていくにつれてメリットを感じられるようになってきました。
例えば、nullやundefinedになる可能性があると実行する前にエラーが表示されるため、早めにif文などでエラーにならないように対応できたり、変数に格納されているデータやオブジェクトが分かりやすいといったメリットを感じました。

Tailwind CSS

UIは主にTailwind CSSを使用しており、モーダルやポップオーバー、タブなどの一部のコンポーネントはMUIを使用しています。
できればTailwind CSSでデザインを統一したかったのですが、モーダルやポップオーバー、タブなどを実装するのに時間がかかりそうだったので、機能がすべて揃っていてドキュメントも充実しているMUIも採用しました。
以前Bootstrapを少し使用したことがありましたが、Tailwind CSSの方がCSSのプロパティがそのままクラス名になっている感じがあり、デザインの細かい調整がしやすかったです。
また、Next.jsは対話形式でTailwind CSSを選択できるのも楽で良かったです。

Supabase

バックエンドはSupabaseを採用しました。
Supabaseは、RDBを使用したい、Next.jsに導入するための情報が多いといった理由で採用しました。
自分でAPIを構築するか迷いましたが、セキュリティ面で心配だったのと、無料で運用したかったので、BaaSを使用しました。

運用・デプロイ

デプロイ先はVercelを選びました。
VecelはGithubに接続して環境変数などを設定するだけで、自動でデプロイが始まるので特につまずくことなくデプロイできました。

開発過程

数式エディタ

このサイトのメイン機能である数式エディタについて説明します。
数式入力画面

まず、数式はTeXが分からない人でも入力できるようにしたかったので、数式エディタのライブラリを使用しました。
数式エディタのライブラリには、MathQuillやmathliveなどのライブラリがありましたが、動作が安定していてカスタマイズしやすそうなMathQuillを選びました。

MathQuillだけでは、ボタンから数式を挿入することはできないため、自分でボタンを作成してrefでDOMにアクセスすることで数式を挿入しています。

また、MathQuillを普通にインポートすると、window is not definedというエラーが出ましたが、Stack Overflowを参考にして、MathQuillを動的インポートすることで解決しました。

数式エディタのソースコード
import { greek, mathFormula, otherSymbols } from "../lib/symbol";
import { ArrowsRightLeftIcon, PlusIcon } from "@heroicons/react/24/outline";
import { TabButton } from "./buttons";
import { useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import rehypeKatex from "rehype-katex";
import remarkMath from "remark-math";
import ReactMarkdown from "react-markdown";
import "katex/dist/katex.min.css";

const EditableMathField = dynamic(
  () => import("react-mathquill").then((mod) => mod.EditableMathField),
  { ssr: false }
);

export default function MathInput({ addLatex, mathInputChange }: Props) {
  const [tabItems, setTabItems] = useState(mathFormula);
  const [latex, setLatex] = useState("");
  const mathfield = useRef<any>(null);

  useEffect(() => {
    import("react-mathquill").then((mq) => {
      mq.addStyles();
    });
  }, []);

  const insertText = (text: string) => {
    mathfield.current.cmd(text);
  };

  const handleAddLatexClick = () => {
    if (latex === "" || latex === "$$") {
      return;
    }
    addLatex(`$${latex}$`);
    setLatex("");
  };

  const btnItems = tabItems.map((symbol, index: number) => {
    return (
      <button
        key={index}
        onClick={() => {
          insertText(symbol.tex);
        }}
        className="rounded-lg bg-gray-200 text-sm font-medium text-gray-600 hover:bg-gray-300 py-2 px-3"
      >
        <ReactMarkdown
          remarkPlugins={[remarkMath]}
          rehypePlugins={[rehypeKatex]}
          className="text-base"
        >
          {symbol.name}
        </ReactMarkdown>
      </button>
    );
  });

  return (
    <>
      <div className="flex justify-end mb-2">
        <button onClick={mathInputChange}>
          <ArrowsRightLeftIcon className="h-9 w-9 bg-sky-400 opacity-80 text-white rounded-full p-2 mr-2 hover:opacity-60" />
        </button>
        <button onClick={handleAddLatexClick}>
          <PlusIcon className="h-9 w-9 bg-sky-400 opacity-80 text-white rounded-full p-2 mr-2 hover:opacity-60" />
        </button>
      </div>
      <div className="min-h-16">
        <EditableMathField
          latex={latex}
          mathquillDidMount={(mf) => {
            mathfield.current = mf;
          }}
          onChange={(mathField) => {
            setLatex(mathField.latex());
          }}
          className="w-full min-h-16 p-2 rounded-sm"
        />
      </div>
      <nav className="flex flex-row pb-2">
        <TabButton
          isActive={tabItems === mathFormula}
          onClick={() => {
            setTabItems(mathFormula);
          }}
        >
          数式
        </TabButton>
        <TabButton
          isActive={tabItems === otherSymbols}
          onClick={() => {
            setTabItems(otherSymbols);
          }}
        >
          記号
        </TabButton>
        <TabButton
          isActive={tabItems === greek}
          onClick={() => {
            setTabItems(greek);
          }}
        >
          ギリシャ文字
        </TabButton>
      </nav>
      <div className="h-72 px-1">
        <div className="flex flex-wrap gap-x-3 gap-y-2 justify-start items-start">
          {btnItems}
        </div>
      </div>
    </>
  );
}

数式の表示

数式の表示には以下のライブラリを使っています。

  • rehype-katex
  • remark-math
  • react-markdown

react-markdownのプラグインでrehype-katexとremark-mathを使うことで$で囲まれた数式をレンダリングしています。

<ReactMarkdown
    remarkPlugins={[remarkMath]}
    rehypePlugins={[rehypeKatex]}
    className="text-base font-medium overflow-hidden h-7 w-full"
>

数式検索

数式検索画面

このサイトでは、質問の検索機能の一部として数式での検索にも対応しています。
数式を入力できるQ&Aサイトならではの機能がないか考えていたときにこの機能を思いつき、実装しました。
検索画面では、検索するワードや数式を複数入力した場合でも、見づらくならないようにEnterを押すとタグで区切られるようになっています。タグで区切る入力は、以下の記事を参考にさせていただきました。
https://zenn.dev/takasy/articles/react-tags-input

入力された検索ワードは、数式で使われない#で区切って、クエリパラメータにのせています。
クエリパラメータは、/searchで取り出してデータベースから該当する質問を検索するという流れになっています。
全文検索については、この後詳しく説明します。

検索画面のソースコード
"use client";
import { useEffect, useRef, useState } from "react";
import { TagsInput } from "./tags-input";
import MathInput from "./math-input";
import CustomLatex from "./custom-latex";
import { useRouter, useSearchParams } from "next/navigation";
import { CalculatorIcon } from "@heroicons/react/24/outline";
import Modal from "@mui/material/Modal";

export default function Search() {
  const searchParams = useSearchParams();
  const { replace } = useRouter();
  const [tags, setTags] = useState<string[]>([]);
  const [isCustomLatex, setIsCustomLatex] = useState(false);
  const [showMathModal, setShowMathModal] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    const defaultTags = searchParams.get("query")?.toString().split("#");
    if (defaultTags) {
      setTags(defaultTags);
      inputRef && inputRef.current?.focus();
    }
  }, []);

  const handleAddLatex = (latex: string) => {
    setShowMathModal(false);
    const params = new URLSearchParams(searchParams);
    const newTags: string[] = [...tags, latex];
    setTags(newTags);
    params.set("query", newTags.join("#"));
    replace(`search?${params.toString()}`);
  };

  const handleAddTag = (newTags: string[]) => {
    const params = new URLSearchParams(searchParams);
    setTags(newTags);
    if (newTags.length !== 0) {
      params.set("query", newTags.join("#"));
    } else {
      params.delete("query");
    }
    replace(`search?${params.toString()}`);
  };

  const handleMathInputChange = () => {
    setIsCustomLatex((prevState) => !prevState);
  };

  return (
    <div className="mx-auto">
      <div className="flex rounded-lg bg-white border border-gray-300">
        <TagsInput
          tags={tags}
          onChangeTags={handleAddTag}
          inputRef={inputRef}
        />
        <button
          type="button"
          aria-label="数式を追加する"
          onClick={() => {
            setShowMathModal(true);
          }}
        >
          <CalculatorIcon className="h-9 w-9 bg-sky-400 opacity-80 text-white rounded-full p-2 mr-2 hover:opacity-60" />
        </button>
      </div>
      <Modal
        open={showMathModal}
        onClose={() => {
          setShowMathModal(false);
        }}
      >
        <div className="w-[380px] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 outline-none">
          <h1 className="text-white text-xl pb-3">数式で検索</h1>
          <div className="bg-white rounded-lg p-3">
            {isCustomLatex ? (
              <CustomLatex
                addLatex={handleAddLatex}
                mathInputChange={handleMathInputChange}
                plusText="数式を追加"
              />
            ) : (
              <MathInput
                addLatex={handleAddLatex}
                mathInputChange={handleMathInputChange}
                plusText="数式を追加"
              />
            )}
          </div>
        </div>
      </Modal>
    </div>
  );
}

データベース (Supabase)

Supabaseでは、主に以下のテーブルを作成しています。

テーブル名 説明
questions 投稿された質問を保存
answers それぞれの質問に対する回答を保存
comments それぞれの回答に対するコメントを保存
supplements それぞれの質問に対する補足情報を保存
question_likes 質問にいいねした人とされた人を保存
answer_likes 回答にいいねした人とされた人を保存
categories 質問のカテゴリ一覧を保存
notifications それぞれのユーザーに対する通知を保存
public_notifications 全ユーザーに対する通知を保存
profiles ユーザーのプロフィールを保存

Row Level Security

SupabaseはRLSをオンにすることが推奨されており、ポリシーを設定せずにリクエストを送ると空の配列が返って来ます。
Supabaseを使い始めて最初につまずいたポイントはここです。

RLSの設定画面

例えば、questionsテーブルでは以下のようなポリシーを設定しています。

  • INSERT
    authenticatedロールを持つユーザー(認証済みのユーザー)が自分のuser_idと一致するquestionを作成できる
create policy "Users can create a question."
on questions for insert
to authenticated
with check ( (select auth.uid()) = user_id );
  • SELECT
    全てのユーザーが全てのquestionを読み取ることができる。
    anonはログインしていないユーザーを指します。
create policy "Questions are viewable by everyone"
on questions for select
to authenticated, anon
using ( true );
  • UPDATE
    authenticatedロールを持つユーザーが、自分のuser_idと一致するquestionを更新できる。
    更新後にuser_idが書き換えられないように、auth.uid()と一致していることを確認する。
create policy "Users can update their own question."
on questions for update
to authenticated
using ( (select auth.uid()) = user_id )       -- 既存の行のチェック
with check ( (select auth.uid()) = user_id ); -- 更新後の行のチェック
  • DELETE
    authenticatedロールを持つユーザーが、自分のuser_idと一致するquestionを削除できる。
create policy "Users can delete a question."
on questions for delete
to authenticated
using ( (select auth.uid()) = user_id );

日本語全文検索 (PGroonga)

質問の検索には、PGroongaというSupabaseの拡張機能を使用しています。
https://supabase.com/docs/guides/database/extensions/pgroonga
PGroongaは日本語の全文検索に対応しており、拡張機能を有効にして、インデックスを作成するだけで簡単に全文検索ができるようになりました。
検索は以下のようなクエリで実行できます。

select id, content from questions where content &@~ '行列';

クライアントからPGroongaを利用するAPIがなかったため、Database Functionsにクエリを実行する関数を作成して、クライアントから関数を実行することでPGroongaを使用しています。
Databesa Functionsには以下のようにして関数を作成できます。

CREATE OR REPLACE FUNCTION public.search_questions(keyword_to_search text)
RETURNS TABLE(id bigint, content text, is_solved boolean)
LANGUAGE plpgsql
AS $function$
BEGIN
    RETURN QUERY
    SELECT
        id,
        content,
        is_solved
    FROM
        questions
    WHERE
        content &@~ keyword_to_search
    ORDER BY created_at DESC;
END;
$function$;

クライアントでは上記の関数をrpcで呼び出しています。

const { data: questions, error } = await supabase.rpc(
    "search_questions",
    { keyword_to_search: query }
);

ユーザー情報の保存

SupabaseのAuthスキーマは、セキュリティ上の理由でクライアントからはアクセスできないようになっているため、publicスキーマにユーザ情報を保存するprofilesテーブルを作成しています。

profilesテーブルには、トリガーを使ってユーザーが作成されたタイミングで、新しいレコードを追加しています。

create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
  insert into public.profiles (id)
  values (new.id);
  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

https://supabase.com/docs/guides/auth/managing-user-data

リージョン

Vercelにデプロイ後に触っていると、Supabaseとの通信で遅延が発生していることが分かりました。
調べてみると、Supabaseのリージョン設定が東京になっていないことが原因だと分かりました。
SupabaseとVercelと同じ東京リージョンに変更することで解決し、ほとんど遅延が発生しなくなりました。 (現在はプロジェクト作成後にリージョンを変更できなくなっているようです)

画像のアップロード

画像のアップロードは、クライアントで画像を圧縮し、Supabaseのストレージにアップロードしています。
画像の圧縮にはbrowser-image-compressionを使用しており、以下のようなヘルパー関数を作成しました。

compress.ts
import imageCompression from "browser-image-compression";
// ...
export const compressImage = async (
  file: File,
  options: compressImageType = {
    maxSizeMB: 20,
    useWebWorker: true,
    initialQuality: 0.85,
  }
): Promise<File> => {
  try {
    const compressedFile = await imageCompression(file, options);
    return compressedFile;
  } catch (err) {
    throw new Error("画像の圧縮に失敗しました");
  }
};

Supabaseには、use clientを使用して、クライアントから直接アップロードしているため、Supabase側でバリデーションをかけています。
セキュリティの面で、サーバーサイドでバリデーションをかけてからアップロードしたかったのですが、うまくいかず断念しました。

const compressedFile = await compressImage(file, { maxWidthOrHeight: 200 });
const filePath = `avatar/${uuidv4()}`;
const { error: uploadError } = await supabase.storage
    .from("ui-math")
    .upload(filePath, compressedFile);
if (uploadError) {
    throw new Error("画像のアップロードに失敗しました");
}

また、画像のプレビューは、window.URL.createObjectURL(compressedFile)で圧縮された画像ファイルの一時的なURLを取得してimgタグのsrcに渡しています。

バリデーション

バリデーションのライブラリには、Zodを使用しています。
ZodはNext.jsのチュートリアルで紹介されており、使いやすそうだったので使用しました。

バリデーションはServer Actions内で行い、バリデーションで発生したエラーは、useFormState(React19ではuseActionState)を使って、クライアントに返しています。

認証について

認証は、以下のガイドを参考にして実装しています。
https://supabase.com/docs/guides/auth/server-side/nextjs

クライアントコンポーネントでSupabaseを利用する場合は、以下のコードだけで動作しますが、サーバーサイドで利用する場合は、@supabase/ssrを使用してミドルウェアを設定する必要があったのが初心者には難しかったです。

import { createClient } from '@supabase/supabase-js'

// Create a single supabase client for interacting with your database
const supabase = createClient('https://xyzcompany.supabase.co', 'public-anon-key')

また、上記のコードで使われているanon-key (publishable-key)は、Supabase側のセキュリティ対策を行っていれば、クライアントのコードに公開してもセキュリティ上の問題はないようです。
セキュリティ対策については以下の記事を参考にさせていただきました。
https://zenn.dev/k_log24/articles/ff1581de72b0aa

デザインについて

pendingの表示

pending中のボタン

フォームのローディング状態は、以下のようにuseFormStatusで取得しています。
pendingtrueの時が、フォームの処理を行っている状態になります。

フォームの中に以下のコンポーネントを配置するだけで、フォームの状態を取得できるのでとても便利でした。

"use client";

import CircularProgress from '@mui/material/CircularProgress';
import { useFormStatus } from "react-dom";
import { type ComponentProps } from "react";

type Props = ComponentProps<"button"> & {
  pendingText?: string;
};

export function SubmitButton({ children, pendingText, ...props }: Props) {
  const { pending, action } = useFormStatus();

  const isPending = pending && action === props.formAction;

  return (
    <button {...props} type="submit" aria-disabled={pending}>
      {isPending ? (
        <>
          <span className='inline-block mr-2'>
            <CircularProgress size={17} color='inherit' />
          </span>
          {pendingText}
        </>
      ) : (
        children
      )}
    </button>
  );
}

React Suspense

スケルトン
コンテンツの読み込み中には、Suspenseを使ってフォールバックを表示させています。
Suspenseを使うことで、コンテンツのロードが完了していなくてもページを表示することができ、ユーザーにできるだけ早く画面を表示することができます。

Suspenseの実装で詰まった点は、非同期処理を含む(Supabaseからデータをフェッチする)コンポーネントをSuspensechildrenにする必要があることです。

例えば、以下のコードの場合は、Questionsコンポーネントに非同期処理を入れる必要があります。
親のコンポーネントでデータを取得して、Questionsコンポーネントのpropsからデータを渡した場合は、フォールバックが表示されません。

<Suspense fallback={<Loading />}>
  <Questions />
</Suspense>

最初に、Suspenseを使わない状態で実装していたため、非同期処理を分ける必要があることに気づかずに詰まっていました。

今後の改善点や課題

ここまで、技術スタックや開発過程について紹介してきましたが、やはり特に課題に感じているのは、ユーザー数です。
機能編の投稿で、ユーザー数が数人増えましたが、サービスとして成り立つには程遠いレベルです。
このサイトを開発し始めた時は、ある程度の検索からユーザーの流入があると思っていましたが、実際に公開してみると全くそんなことはなく、SNS系サイトを運営する難しさを痛感しました。

開発にはかなりの時間をかけてきましたが、現在は他にも開発したいアプリがあり、このサービスの集客をするモチベーションが下がってしまっている状況です。

おわりに

この記事では、数式を入力できるQ&Aサイトに使用した技術や開発過程について紹介しました。
ユーザー数の問題はありますが、このサービスを開発することで、React, Next.jsなどのモダンなフロントエンドや、Webアプリ全体の理解を深めることができる良い機会になりました。

今後もサイトの公開は続けていこうと思うので、少しでも興味を持った方がいらっしゃれば、ユーザー登録をしていただけると嬉しいです!

ここまで読んだいただきありがとうございました。

https://www.ui-math.site

Discussion