プレスリリースアワードマイページの技術スタック ~ Hono × React Router SPA Mode のフルスタック TypeScript による効率的な開発 ~

  • URLをコピーしました!

こんにちは、フロントエンドエンジニアのやなぎ(@apple_yagi)です。

PR TIMES では 2021 年からプレスリリースアワードを開催しており、1 年間の内に日本で発表されたプレスリリースの中から、社会性・公共性・共感性・将来性等の視点から、プレスリリースの可能性拡大に貢献したものを審査・選考し、表彰しています。

Press Release Awards | プレスリ...
Press Release Awards | プレスリリースアワード | PR TIMES プレスリリース発信文化の普及と発展のためにPR TIMESが立ち上げたプレスリリースアワードを紹介します。プレスリリースの可能性拡大に貢献したものに対し、イノベーティブ...

今年から新たな取り組みとして、プレスリリースアワード用のマイページをリリースしました。開発にあたっては、フロントエンドとバックエンドの両方で TypeScript を使用するフルスタック TypeScript の構成を採用しました。本記事では、なぜフルスタック TypeScript を選択したのか、そして実際に使用したフレームワークや実装の詳細についてご紹介します。

目次

今回開発したアプリケーションの概要

今回開発したプレスリリースアワードのマイページは、プレスリリースアワードでエントリーしたプレスリリースの確認と、プレスリリース毎に生成されたエントリー証をダウンロードすることができます。以下の画像は実際のページになります。

また、マイページの詳細については下記のプレスリリースでもご紹介していますので、ぜひご覧ください。

プレスリリース・ニュースリリース...
過去最多4573件の中から最終審査に進む「Best101」発表!プレスリリースアワード2025 株式会社PR TIMESのプレスリリース(2025年9月29日 11時30分)過去最多4573件の中から最終審査に進む「Best101」発表!プレスリリースアワード2025

アプリケーション自体は小規模だったため、実装の大部分を私一人で担当しました。アプリケーションの特性として、全ページログインが必須であるため SEO対策は要件から外しています。また、実装したAPI エンドポイントは約7本で、機能もコンパクトにまとまっています。このような規模感と要件から、新しい技術スタックを試すには最適なプロジェクトでした。

フルスタック TypeScript を選択した理由

フルスタック TypeScript を採用した主な理由は以下の 2 点です。

開発環境構築の簡素化

フロントエンドとバックエンドで言語を統一することで、開発環境の構築が大幅に簡素化されました。PR TIMES のフロントエンド開発環境では Docker を使用せず、ローカルの Node.js 環境を直接使用する方針をとっています。そのため、TypeScript 一本に統一することで、環境構築の手間を最小限に抑えることができました。

Hono RPC による型安全な API 通信

Hono RPC は、Hono フレームワークが提供する TypeScript のエンドツーエンド型安全性を実現する機能です。従来の REST API では、フロントエンドとバックエンドで別々に型定義を管理する必要がありましたが、Hono RPC を使用することで、バックエンドで定義した API の型情報をフロントエンドで直接利用できます。

あわせて読みたい
RPC - Hono Web framework built on Web Standards for Cloudflare Workers, Fastly Compute, Deno, Bun, Vercel, Node.js, and others. Fast, but not only fast.

今回の実装では、OpenAPI スキーマなどを別途用意することなく、フロントエンドとバックエンド間で完全に型安全な通信を実現できました。この仕組みにより、API の変更が即座にフロントエンドの型情報に反映され、開発速度の大幅な向上につながりました。

技術スタック

バックエンド

  • Hono: Web 標準に基づいた軽量な Web フレームワーク
  • Prisma: 型安全な ORM で、データベースアクセスを簡潔に記述

フロントエンド

インフラ構成

prtimes.jp はインフラ基盤にAWSを採用していますが、本アプリケーションではGoogle Cloudを採用しました。本アプリケーションのバックエンドをコンテナベースでデプロイする際、AWS の ECS は Google Cloud の Cloud Run と比べて設定やデプロイが複雑だったため、Cloud Run を採用しました。Google Cloud のサービス構成は以下の通りです。

  • Cloud Run: バックエンド API のホスティング
  • Load Balancer: トラフィックの分散処理
  • Google Cloud Storage: フロントエンドの静的ファイル配信
  • Cloud SQL: データベース
  • Memorystore for Valkey: セッション管理用のキャッシュストア

モノレポ構成

Hono RPC による型共有を行うため、フロントエンドとバックエンドを pnpm を使用したモノレポ構成で管理しています。

press-release-awards-app/
├── apps/
│   ├── backend/  # バックエンド
│   └── frontend/ # フロントエンド
├── pnpm-workspace.yaml
├── package.json
└── pnpm-lock.yaml

技術選定の観点

今回のアプリケーションは頻繁に更新するものではないため、技術選定においては「1年後にライブラリのバージョンを最新に上げても最小限の変更で動作し続ける」ことを重視しました。

React Router は過去に破壊的な変更が多かったため、今後も同様の変更が起こる可能性は否定できません。ただし、今回のアプリケーションでは使用範囲を最小限に留めているため、大きな影響は受けにくいと判断しました。また、現在は Open Governance の下で開発が進められており、Design Goalsには以下のように記載されています。このことから、破壊的変更が頻発する可能性は低いと考えています。

• Regular Release Cadence. Aim for major SemVer releases on a ~yearly basis so application developers can prepare in advance.

日本語訳

定期的なリリースペースを維持します。メジャーバージョン(SemVer方式)はおよそ年に1回リリースすることを目指し、アプリケーション開発者が事前に備えられるようにします。

引用元:https://github.com/remix-run/react-router/blob/main/GOVERNANCE.md#design-goals

また、Hono は Web 標準に準拠し、提供する機能も最小限に絞られているため、破壊的変更のリスクが低いフレームワークと考えています。実際、これまでの破壊的変更も影響範囲が限定的でした。

フロントエンドの実装

ViteのProxyを用いたローカル開発環境でのCORS回避

インフラ構成でご紹介した通り、Google Cloud へデプロイした際には Load Balancer を利用し、パスベースでフロントエンドとバックエンドへの通信を制御しています。ローカル環境でも同様にパスベースでリクエストを振り分けるため、Vite の server.proxy を利用しました。

Hono の開発サーバーをポート 3000 で起動する場合、以下のように設定できます。

// vite.config.ts
import process from 'node:process';
import {reactRouter} from '@react-router/dev/vite';
import {defineConfig, type ProxyOptions} from 'vite';

export default defineConfig(({mode}) => {
  // 開発環境の時は、バックエンドのAPIをプロキシする
  const proxy =
    mode === 'development'
      ? ({
          '/api': {
            target: 'http://localhost:3000',
            changeOrigin: true,
          },
        } satisfies Record<string, string | ProxyOptions>)
      : undefined;

  return {
    plugins: [reactRouter()],
    server: {
      proxy,
    },
  };
});

この設定により、Vite の開発サーバーをポート 5173 で起動した際にhttp://localhost:5173/api/hello へリクエストすると、実際にはhttp://localhost:3000/api/hello へ転送されます。こうすることで、ローカル環境でも本番環境と同様に CORS を回避しながら開発を進められます。

React Router v7のClientMiddleware機能を活用した認証処理の実装

フロントエンドの認証制御には先日 stable となった Middleware を使用しています。以下はサンプルコードになります。

// app/middlewares/auth-middleware.ts
import {createContext, type MiddlewareFunction} from 'react-router';
import {client} from '../api/index.js';

export type CurrentUser = {
  id: number;
  companyId: number | null;
  companyName: string | null;
  name: string;
  email: string;
};

export const currentUserContext = createContext<CurrentUser | null>();

export const authMiddleware: MiddlewareFunction = async ({context}) => {
  const response = await client.api.me.$get();
  if (!response.ok) {
    context.set(currentUserContext, null);
    return;
  }

  const data = await response.json();
  context.set(currentUserContext, data.user);
};
// app/layouts/my-page-layout.tsx
import {Outlet, redirect, useNavigate} from 'react-router';
import {
  authMiddleware,
  currentUserContext,
} from '../../middlewares/auth-middleware.js';
import {Footer} from '../../components/footer/index.js';
import {Header} from './header/index.js';
import styles from './my-page-layout.module.css';
import type {Route} from './+types/index.js';

export const clientMiddleware = [authMiddleware];

export function clientLoader({context}: Route.ClientLoaderArgs) {
  const currentUser = context.get(currentUserContext);
  if (!currentUser) {
    throw redirect('/login/');
  }

  return {currentUser};
}

export default function MyPageLayout({loaderData}: Route.ComponentProps) {
  const navigate = useNavigate();
  const redirectAfterLogout = async () => {
    await navigate('/login/');
  };

  return (
    <div className={styles.root}>
      <Header
        {...loaderData.currentUser}
        redirectAfterLogout={redirectAfterLogout}
      />
      <main className={styles.main}>
        <Outlet />
      </main>
      <Footer />
    </div>
  );
}

authMiddleware はログインしているユーザー情報を context にセットし、その情報を MyPageLayout の clientLoader で context から情報を取得し、情報がなかった場合、ログインページにリダイレクトしています。

ログインページでは、既に認証済みの場合にホームへリダイレクトする処理を行っています。

// app/routes/login/page.tsx
import {redirect} from 'react-router';
import {
  authMiddleware,
  currentUserContext,
} from '../../middlewares/auth-middleware.js';
import type {Route} from './+types/page.js';

// 同じミドルウェアを使用
export const clientMiddleware = [authMiddleware];

export function clientLoader({context}: Route.ClientLoaderArgs) {
  const currentUser = context.get(currentUserContext);

  if (currentUser) {
    // 認証済みの場合はホームへリダイレクト
    throw redirect('/');
  }
}

export default function LoginPage() {
  // ログインフォームを表示
  return (
    <div>
      <LoginForm />
    </div>
  );
}

clientMiddlewareを使用することにより、認証で必要となるデータ取得を一箇所にまとめることができ、コードの可読性が向上します。

React Router SPA Mode を Cloud Storage にデプロイする際のポイント

SPA をデプロイする場合、通常はすべてのリクエストを index.html にリライトする設定が必要です。このリライト機能は Firebase Hosting など一部のホスティングサービスでは提供されていますが、Cloud Storage や Amazon S3 のようなオブジェクトストレージには標準では用意されていません。そのため、オブジェクトストレージを利用する場合には、Cloud CDN や Cloud Functions など、別途リクエストリライトを実現できる仕組みを組み合わせる必要があります。

Firebase
ホスティング動作を構成する  |  Firebase Hosting カスタマイズされた Firebase Hosting の動作を構成するための包括的なガイド。デプロイするファイルの指定、リダイレクトとリライトの設定、ヘッダーの管理も含まれます。

この制約を回避するため、React Router の prerender 機能を活用しました。prerender を有効にすることで、全てのルートに対応する HTML ファイルを事前に生成できます。これらのファイルを Cloud Storage や S3 上に配置すれば、各ルートへのリクエストがそれぞれの HTML ファイルに直接マッピングされるため、リライト機能がなくても SPA を配信することが可能となります。

// react-router.config.ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: false,
  prerender: true,
} satisfies Config;
// 出力されるHTMLファイル
index.html
login/index.html
detail/index.html
...

ただし、prerender を使用する際に一つ課題がありました。パスパラメータを含むルート(例: /press-release/:id )の場合、事前に全ての HTML ファイルを生成することができません。この制約を解決するため、パスパラメータではなくクエリパラメータを使用する設計にしました。

// 🙅‍♂️ パスパラメータ(prerenderできない)
/press-release/:id

// 🙆‍♂️ クエリパラメータ(prerenderできる)
/press-release/detail?id=1

この方式により、detail/index.htmlが生成され、Cloud Storage でも rewrite 設定なしで正常に動作するようになりました。

バックエンドの実装

Hono RPCの活用

Hono RPCを使用する場合、ルートをメソッドチェーンで定義する必要があります。

// Bad
const routes = new Hono();
routes.get('/health', (c) => c.json({ ok: true }));
routes.get('/test', (c) => c.json({ test: 'test' }));
type AppType = typeof routes; // 型推論されない

// Good
const routes = new Hono()
  .get('/health', (c) => c.json({ ok: true }))
  .get('/test', (c) => c.json({ test: 'test' }));
type AppType = typeof routes; // 型推論される

しかし、ルートの定義と一緒にハンドラーの実装を行なってしまうとコードが見辛くなってしまうため、hono/factorycreateFactory を使用してハンドラーを別ファイルに切り出す構成にしました。

// src/api/factory/create-handlers.ts
import type { SessionEnv } from "@hono/session";
import { createFactory } from "hono/factory";
import type { MailClientEnv } from "../middleware/inject-mail-client-middleware.js";
import type { LoggerEnv } from "../middleware/inject-logger-middleware.js";
import type { RequireAuthEnv } from "../middleware/require-auth-middleware.js";
import type { WithCurrentUserEmailEnv } from "../middleware/with-current-user-email-middleware.js";

const factory = createFactory<
  SessionEnv &
    MailClientEnv &
    LoggerEnv &
    WithCurrentUserEmailEnv &
    RequireAuthEnv
>();

export const createHandlers = factory.createHandlers.bind(factory);
// src/api/handlers/me/get-press-release-list-handler.ts
import { pressReleaseService } from "../../../service/press-release-service.js";
import { createHandlers } from "../../factory/create-handlers.js";
import { requireAuthMiddleware } from "../../middleware/require-auth-middleware.js";

export const getPressReleaseListHandler = createHandlers(
  requireAuthMiddleware,
  async (c) => {
    const { currentUser } = c.var;

    const pressReleases = await pressReleaseService.getListByUserEmail(
      currentUser.email
    );

    return c.json(
      pressReleases.map((pressRelease) => ({
        id: pressRelease.id,
        companyId: pressRelease.companyId,
        title: pressRelease.title,
        entryCertificateImage: pressRelease.entryCertificateImage,
      })),
      200
    );
  }
);

ハンドラーを別ファイルに切り出すことで、メソッドチェーンでルートを定義しつつもコードの可読性を保つことができます。

// src/api/routes.ts
import {Hono} from 'hono';
import {useSession, useSessionStorage} from '@hono/session';
import {envs} from '../envs.js';
import {getHealthHandler} from './handlers/health.js';
import {postSendOtpHandler} from './handlers/auth/send-otp-handler.js';
import {postVerifyOtpHandler} from './handlers/auth/verify-otp-handler.js';
import {getMeHandler} from './handlers/me/get-me-handler.js';
import {redisSessionStorage} from './session-storage/redis.js';
import {injectMailClientMiddleware} from './middleware/inject-mail-client-middleware.js';
import {logoutHandler} from './handlers/auth/logout-handler.js';
import {injectLoggerMiddleware} from './middleware/inject-logger-middleware.js';
import {getPressReleaseListHandler} from './handlers/me/get-press-release-list-handler.js';
import {getPressReleaseHandler} from './handlers/me/get-press-release-handler.js';
import {postDownloadEntryCertificateImageLog} from './handlers/me/post-download-entry-certificate-image-log.js';
import {withCurrentUserEmailMiddleware} from './middleware/with-current-user-email-middleware.js';

const routes = new Hono()
  .use(
    useSessionStorage(redisSessionStorage),
    useSession({
      secret: envs.AUTH_SECRET,
      duration: {
        absolute: 60 * 60 * 24 * 30, // 30 days
      },
    }),
  )
  .use('*', injectMailClientMiddleware)
  .use('*', injectLoggerMiddleware)
  .use('*', withCurrentUserEmailMiddleware)
  .basePath('/api')
  .get('/health', ...getHealthHandler)
  .post('/auth/send-otp', ...postSendOtpHandler)
  .post('/auth/verify-otp', ...postVerifyOtpHandler)
  .post('/auth/logout', ...logoutHandler)
  .get('/me', ...getMeHandler)
  .get('/me/press-releases', ...getPressReleaseListHandler)
  .get('/me/press-releases/:id', ...getPressReleaseHandler)
  .post(
    '/me/press-releases-entry-certificate-image-log',
    ...postDownloadEntryCertificateImageLog,
  );

export {routes};

そして、この routes から typeof で型を抽出し、package.json の exports フィールドに AppType を export している TypeScript ファイルを設定することで、AppType を frontend から参照できるようにしています。

// src/index.ts
import type { routes } from "./api/routes.js";

export type AppType = typeof routes;
// package.json
{
  ...
  "exports": {
    ".": "./src/index.ts"
  }
  ...
}

フロントエンド側ではこの型定義を import して API クライアントを生成します。これにより、バックエンドの API 変更がリアルタイムでフロントエンドの型情報に反映され、開発中の型安全性が保証されます。

// apps/frontend/src/api/client.ts
import { hc } from "hono/client";
import type { AppType } from "@press-release-awards-app/backend";

export const client = hc<AppType>(globalThis.location.origin);

HonoのMiddlewareを使用した外部依存の注入

本アプリケーションのログイン機能は、メールにワンタイムパスワードを送信することで認証を行います。そのため、メール送信を行うクライアントなどを実装する必要がありました。これらの外部サービスとの連携部分は、middleware を通じてインスタンスを注入する設計にしました。この実装により、ローカル開発環境では簡単にダミーのメールクライアントに差し替えることができます。

import { type Context, type MiddlewareHandler } from "hono";
import type { MailClientInterface } from "../../libs/mail/client/mail-client-interface.js";
import { envs } from "../../envs.js";
import { SendGridMailClient } from "../../libs/mail/client/send-grid/index.js";
import { LocalFileMailClient } from "../../libs/mail/client/local-file/index.js";

export type MailClientEnv = Context & {
  Variables: {
    // mailClient の型には interface を指定しておくことで、この interface を implements したインスタンスと差し替え可能にする
    mailClient: MailClientInterface;
  };
};

const isLocal = envs.APP_ENV === "development";

export const injectMailClientMiddleware: MiddlewareHandler = async (
  c,
  next
) => {
  c.set(
    "mailClient",
    // ローカル環境の場合、メールをローカルのファイルに出力する
    isLocal ? new LocalFileMailClient() : new SendGridMailClient()
  );
  await next();
};

ハンドラーで c.var から mailClient を取得し使用することができます。

import { sValidator } from "@hono/standard-validator";
import z from "zod";
import { createHandlers } from "../../factory/create-handlers.js";

export const postSendMailHandler = createHandlers(
  sValidator(
    "json",
    z.object({
      email: z.email(),
    })
  ),
  async (c) => {
    const { email } = c.req.valid("json");
    const { mailClient } = c.var;

    await mailClient.sendMail({
      to: email,
      from: 'test@prtimes.co.jp',
      subject: "テストメール",
      body: "これはテストメールです。",
    });

    return c.json({ status: "ok" });
  }
);

LocalFileMailClient の実装を以下のようにすることで指定したパスにメールをテキストで保存しています。ファイルに保存する必要がなければ、consoleなどに出力することも可能です。

import { writeFileSync } from "node:fs";
import type { Mail, MailClientInterface } from "../mail-client-interface.js";

export class LocalFileMailClient implements MailClientInterface {
  async sendMail(mail: Mail): Promise<void> {
    writeFileSync(
      `mail-box/${mail.subject}.txt`,
      `To: ${mail.to}
From: ${mail.from}
------------------------------
${mail.body}`,
      "utf8"
    );
  }
}

Playwright によるエントリー証の生成

プレスリリースアワードでは、エントリーされた各プレスリリースに対してエントリー証の画像の生成を行いました。以下の画像はサンプルになります。

エントリー証には各プレスリリースのエントリーNo、タイトルなどを埋め込む必要があるため、動的に画像を生成する必要がありました。今回は Playwright を使用して以下のように実装しました。

import { chromium } from 'playwright';
import sharp from 'sharp';
import { readFile } from 'node:fs/promises';

export async function generateEntryCertificate(data: {
  entryNo: string;
  pressReleaseTitle: string;
  companyName: string;
  personName: string;
  entryDate: string;
}) {
  // 1. テンプレート画像とフォントをBase64エンコード
  const templateImage = await readFile('./template.png');
  const templateBase64 = templateImage.toString('base64');

  const fontFile = await readFile('./NotoSansJP-Medium.ttf');
  const fontBase64 = fontFile.toString('base64');

  // 2. テンプレート画像のサイズを取得(Sharp使用)
  const metadata = await sharp('./template.png').metadata();
  const width = metadata.width ?? 800;
  const height = metadata.height ?? 600;

  // 3. HTMLコンテンツを生成(動的にフォントサイズ調整)
  const fontSize = data.pressReleaseTitle.length > 90 ? '18px' : '24px';
  const htmlContent = `<!DOCTYPE html>
<html lang="ja">
<head>
  <style>
    @font-face {
      font-family: 'CustomFont';
      src: url('data:font/truetype;base64,${fontBase64}') format('truetype');
    }

    body {
      margin: 0;
      width: ${width}px;
      height: ${height}px;
      font-family: 'CustomFont', sans-serif;
      background-image: url('data:image/png;base64,${templateBase64}');
      background-size: cover;
      color: white;
      text-align: center;
      position: relative;
    }

    .entry-no {
      position: absolute;
      top: 170px;
      left: 50%;
      transform: translateX(-50%);
      font-size: 24px;
    }

    .title {
      position: absolute;
      top: 210px;
      left: 50%;
      transform: translateX(-50%);
      font-size: ${fontSize};
      width: 480px;
      line-height: 1.3;
      break-word: auto-phrase;
    }

    .company {
      position: absolute;
      top: 350px;
      left: 50%;
      transform: translateX(-50%);
      font-size: 24px;
    }

    .date {
      position: absolute;
      bottom: 25px;
      right: 50px;
      font-size: 16px;
    }
  </style>
</head>
<body>
  <div class="entry-no">エントリーNo.${escape(data.entryNo)}</div>
  <div class="title">『${escape(data.pressReleaseTitle)}』</div>
  <div class="company">${escape(data.companyName)} ${escape(data.personName)} 様</div>
  <div class="date">エントリー日時:${escape(data.entryDate)}</div>
</body>
</html>`;

  // 4. Playwrightで画像生成
  const browser = await chromium.launch();
  const page = await browser.newPage();

  try {
    // ビューポート設定とHTML読み込み
    await page.setViewportSize({ width, height });
    await page.setContent(htmlContent, { waitUntil: 'networkidle' });

    // フォント読み込み待機
    await page.evaluate(() => document.fonts.ready);

    // スクリーンショット取得
    const imageBuffer = await page.screenshot({
      type: 'png',
      fullPage: true
    });

    return imageBuffer;
  } finally {
    await browser.close();
  }
}

Playwright を選択した理由

画像生成の実装において重要だったのが、プレスリリースのタイトルの改行位置を自然に調整することでした。この実現のために、CSS のword-break: 'auto-phrase'プロパティを使用しています。

Playwright以外の選択肢として、軽量で高速な画像生成が可能な vercel/satori も検討しましたが、word-breakauto-phrase値がまだ未実装という制約がありました。

また、Playwrightは実際のブラウザを起動するため、画像生成用途で利用する場合パフォーマンス面で懸念されることが多いですが、今回はエントリー証をバッチ処理でまとめて生成しており、オンデマンドでの高速な生成は求められていませんでした。そのため、パフォーマンスよりも、「日本語の改行位置を自然な形にしたい」「CSS仕様を確実にサポートし、将来的にも仕様変更に安定して追従できる」という観点を重視しました。

vercel/satori は高パフォーマンスが特長ですが、バージョンアップ時にスタイルが崩れる等のバグが報告されており、長期的な安定運用には不安が残ると判断しました。

日本語の改行位置を自然にするには auto-phrase が不可欠だったため、Playwright を選択しました。Playwright は実際のブラウザエンジンを使用するため、CSS 仕様を完全にサポートしており、期待通りの表示を実現できました。

以下の画像は、word-break: 'auto-phrase'を適用する前後の比較です。適用前は「ハッカソン」の文字の途中で強制的に改行されていますが、適用後は単語の途中で改行が入ることはなく、より自然なレイアウトになっています。

auto-phrase適用前
auto-phrase適用後

まとめ

今回はプレスリリースアワードのマイページ開発において、フルスタック TypeScript を採用した事例をご紹介しました。

Hono RPC による型安全な API 通信、React Router の prerender による Cloud Storage 対応、Playwright を使った動的な画像生成など、様々な技術的チャレンジがありましたが、TypeScript で統一された開発環境により、効率的に開発を進めることができました。

特に、小規模なチームでの開発においては、言語を統一することで得られる開発効率の向上は大きなメリットだと感じました。今後も適切な場面では、フルスタック TypeScript の採用を検討していきたいと思います。

We are hiring!

PR TIMES では、フロントエンドエンジニアを含む各種ポジションでの採用を進めています!興味があればぜひご応募ください。

あわせて読みたい
株式会社PR TIMES
採用情報|株式会社PR TIMES 株式会社PR TIMESの採用情報(新卒採用・キャリア採用)をご紹介します。
  • URLをコピーしました!

この記事を書いた人

株式会社PR TIMES 開発本部 フロントエンドエンジニア

目次