オンライン履歴書エディターをゼロから構築:Next.js 16 + React 19 + Koa フルスタック実践(ちらつきのないPDFリアルタイムプレビュー付き)

前端工程

キーワード:Next.js 16 / React 19 / @react-pdf/renderer / PDFリアルタイムプレビュー / Tiptap / Koa2 / Sequelize / SEOエンジニアリング / 多言語i18n

読了時間:約25分 | コード比率:40% | 対象読者:中上級フロントエンド・フルスタックエンジニア


はじめに

「オンライン履歴書エディター」を作るのは、一見すると単純なCRUDプロジェクトに見えます。しかし実際に作り始めてみると、次々と深い落とし穴に直面しました:

  • ユーザーが1文字入力するたびにPDFを再生成するのか?1回の生成に300msかかり、白いちらつきでUXが完全に崩壊する;
  • ブラウザで印刷したPDFとプレビューが一致しない——フォント、行間、改ページがすべて乱れる;
  • 中国語の履歴書に英単語が混ざると、@react-pdf/rendererCJK文字を不適切な位置で改行してしまう;
  • 中国語フォントのTTFファイルは10MB超、全量読み込みでファーストビューが壊滅的に;
  • 履歴書の共有リンクに自動採番IDを使うと、id+1全ユーザーの履歴書を巡回できてしまう
  • 7テンプレート × 13モジュール × 4言語、適切に抽象化しないと1箇所の変更で全体が崩壊する。

この記事では、BeautyResumehttps://beautyresume.com)という本番プロジェクトで実際に遭遇した問題と最終的な解決策を完全に解説します。すべて**実運用可能な本物のコード**です。


一、技術選定:なぜこの構成なのか

1.1 全体アーキテクチャ

┌─────────────────────────────────────────────────────────┐
│                      ブラウザ / クライアント               │
│  ┌──────────────┐  ┌──────────────┐  ┌───────────────┐  │
│  │  フォーム編集  │→ │ Redux Store  │→ │  PDFプレビュー  │  │
│  │   (Tiptap)   │  │ (RTK Slice)  │  │ (PDF.js Canvas)│  │
│  └──────────────┘  └──────────────┘  └───────────────┘  │
└──────────────────────────┬──────────────────────────────┘
                           │ HTTPS
┌──────────────────────────▼──────────────────────────────┐
│          Nginx(リバースプロキシ / 静的リソース / Gzip)    │
└──────────┬────────────────────────────┬─────────────────┘
           │                            │
┌──────────▼──────────┐      ┌──────────▼─────────────────┐
│  Next.js 16 SSR     │      │   Koa 2 API Server         │
│  (standalone出力)    │      │   (PM2 クラスターモード)      │
│  App Router         │      │   Session + CSRF + レート制限  │
└─────────────────────┘      └──────────┬─────────────────┘
                                        │
                            ┌───────────▼──────────┐
                            │  MySQL 8 + Sequelize │
                            │  Alibaba Cloud OSS   │
                            └──────────────────────┘

1.2 技術スタック一覧

フロントエンドfrontend/package.json):

領域 選定 バージョン 選定理由
フレームワーク Next.js 16.2.12 App Router + output: 'standalone'、SEOとデプロイサイズの両立
UIライブラリ React 19.2.3 並行処理機能 + useTransitionが重い再レンダリングで必須
状態管理 Redux Toolkit ^2.5.0 履歴書データは深くネストした大規模オブジェクト、細粒度購読が必要
PDF生成 @react-pdf/renderer ^4.3.2 ReactでPDFを記述、プレビューとエクスポートで同一コードを共有
PDFレンダリング react-pdf (PDF.js) ^10.4.1 自前canvasレンダリング、ブラウザ内蔵ビューアを回避
リッチテキスト Tiptap ^3.20.4 ProseMirrorコア、汚いHTMLではなく構造化JSONを出力
i18n next-intl ^4.13.4 App Routerの[locale]セグメントと自然に統合
スタイリング Tailwind CSS ^4 アトミック、PDF側のStyleSheetと一貫した考え方
セキュリティ sanitize-html ^2.17.6 リッチテキストXSS防御
E2E Playwright ^1.62.1 コアパスのリグレッションテスト

バックエンドbackend/package.json):

領域 選定 理由
フレームワーク Koa 2.16 オニオンモデルが「統一ログ/エラー/レスポンス形式」に非常に親和的
ORM Sequelize 6.37 パラメータ化クエリでインジェクションを自然に防止
データベース MySQL 8 (mysql2)
認証 koa-session 7 JWTは使わない、理由は3.2節を参照
バリデーション Joi 17 宣言的スキーマ、統一された境界
パスワード bcryptjs 3
キャプチャ svg-captcha ネイティブcanvas依存なし、デプロイが容易

直感に反する選択:認証にはJWTを捨て、Sessionに戻りました。理由は単純です——履歴書はプライバシーデータであり、「パスワード変更後に全端末で即座にログアウト」する能力が必要です。JWTのステートレス性はこのシナリオでは致命的な欠陥になります(一度発行すると取り消せない)。


二、核心的難題その1:PDFリアルタイムプレビューを「ちらつかず、カクつかず、WYSIWYG」に

2.1 問題の明確化

  • WYSIWYGは、プレビューが最終的なPDFそのものであることを要求する;
  • しかし実際にPDFを生成するのは重い処理で、2ページでも200〜400msかかる;
  • ユーザーは連続入力しており、1文字ごとに生成をトリガーするとUIは激しく揺れる。

私たちのアプローチ:プレビューとエクスポートで同一のDocumentコードを共有し、エンジニアリング手法でパフォーマンスとちらつき問題を解決する。

2.2 唯一のDocumentエントリーポイント

frontend/components/pdf/index.tsx

// PDF履歴書ドキュメント — プレビューとダウンロードで同一コードを共有
const pdfTemplateComponentById: Record<string, ComponentType<PdfTemplateComponentProps>> = {
  ATemplate, BTemplate, CTemplate, DTemplate, ETemplate, FTemplate, GTemplate,
};

export function ResumeDocument({
  data, templateId, themeColor = DEFAULT_THEME_COLOR,
  pdfBodyPx = DEFAULT_PDF_BODY_PX, locale, labels,
}: ResumeDocumentProps) {
  ensurePdfFontsForRender(locale);
  const pdfFontFamily = resolvePdfFontFamily(locale);
  const resolvedId = resolvePdfTemplateId(templateId);
  const Template =
    pdfTemplateComponentById[resolvedId] ??
    pdfTemplateComponentById[defaultPdfTemplateId] ?? ATemplate;

  return (
    <Document>
      <Template data={data} themeColor={themeColor}
        pdfBodyPx={pdfBodyPx} pdfFontFamily={pdfFontFamily} labels={labels} />
    </Document>
  );
}

プレビューページ、ダウンロードボタン、サムネイル生成、公開共有ページ——4つのエントリーポイントすべてがこの1つのコンポーネントを呼び出します

2.3 なぜ<PDFViewer>を使わないのか

<PDFViewer>はiframeにblobを埋め込み、ブラウザ内蔵PDFビューアに委譲します。問題点:

  1. Chrome内蔵ビューアはツールバーを強制追加、自動ズーム/クロップで視覚的に汚い;
  2. blob変更のたびにiframe全体がリロードされ、白いちらつきが発生;
  3. モバイルSafariでは動作が完全に異なり、ほぼ使用不可能。

代わりに:usePDFでblob URLを取得 → react-pdf(PDF.js)でcanvas自前レンダリング。

import { usePDF } from '@react-pdf/renderer';
import { Document as PdfJsDocument, Page, pdfjs } from 'react-pdf';

function ensurePdfWorker() {
  if (workerConfigured || typeof window === 'undefined') return;
  pdfjs.GlobalWorkerOptions.workerSrc = publicAssetUrl('/pdf.worker.min.mjs');
  workerConfigured = true;
}

export function ResumePdfJsPreview({ document: pdfDocument, surfaceColor = '#f0f3fd' }) {
  ensurePdfWorker();
  const [instance, updateInstance] = usePDF();
  useEffect(() => {
    updateInstance(pdfDocument as Parameters<typeof updateInstance>[0]);
  }, [pdfDocument, updateInstance]);
}

デプロイの詳細postinstallフックにnode scripts/copy-pdf-worker.jsを追加し、workerをnode_modulesからpublic/にコピー。

2.4 核心テクニック:ダブルバッファリング + 全ページレンダリング完了後に切り替え

コンピュータグラフィックスにおけるダブルバッファリングに由来します。

私たちのアプローチ

  1. 新しいPDFが到着しても、現在表示中のレイヤーには触れない
  2. 裏側に新しいレイヤーを密かに重ね(opacity: 0)、レンダリングを開始;
  3. 各ページのonRenderSuccessを監視し、Setでカウント;
  4. 全ページが揃った時点で新レイヤーをフェードイン、旧レイヤーをフェードアウト;
  5. 260msのフェードアウトアニメーション終了後に旧レイヤーのメモリを回収。
const commitFrame = useCallback((url: string) => {
  if (currentUrlRef.current !== url) return;
  const previous = visibleUrlRef.current;
  if (previous === url) return;

  fadeTimersRef.current.forEach((timer) => window.clearTimeout(timer));
  fadeTimersRef.current = [];

  visibleUrlRef.current = url;
  setVisibleUrl(url);

  if (previous) {
    fadingUrlRef.current = previous;
    setFadingUrl(previous);
    setFrames((prev) => prev.filter((f) => f.url === url || f.url === previous));

    const clearOldTimer = window.setTimeout(() => {
      setFrames((prev) => prev.filter((f) => f.url !== previous));
      setFadingUrl((current) =>
        current !== previous ? current : ((fadingUrlRef.current = null), null)
      );
      renderedPagesByUrlRef.current.delete(previous);
    }, FRAME_FADE_MS);
    fadeTimersRef.current = [clearOldTimer];
  }
}, []);

const handlePageRenderSuccess = useCallback(
  (url: string, pageNumber: number, pages: number) => {
    if (currentUrlRef.current !== url || pages <= 0) return;
    let renderedPages = renderedPagesByUrlRef.current.get(url);
    if (!renderedPages) {
      renderedPages = new Set();
      renderedPagesByUrlRef.current.set(url, renderedPages);
    }
    renderedPages.add(pageNumber);
    if (renderedPages.size >= pages) commitFrame(url);
  },
  [commitFrame]
);

レイヤー本体はmemoでラップし、テキストレイヤーと注釈レイヤーを無効化(レンダリング時間30%以上削減):

const PdfFrameLayer = memo(function PdfFrameLayer({
  frame, pageWidth, visible, fading, onPageRenderSuccess,
}) {
  return (
    <div style={{
      opacity: visible ? 1 : 0, pointerEvents: 'none',
      transition: fading ? `opacity ${FRAME_FADE_MS}ms ease` : undefined,
      zIndex: fading ? 3 : visible ? 2 : 0,
    }} aria-hidden={!visible}>
      <PdfJsDocument file={frame.url} loading={null} error={null}>
        {Array.from({ length: frame.pages }, (_, i) => (
          <Page key={i} pageNumber={i + 1} width={pageWidth}
            renderTextLayer={false} renderAnnotationLayer={false}
            onRenderSuccess={() => onPageRenderSuccess(frame.url, i + 1, frame.pages)} />
        ))}
      </PdfJsDocument>
    </div>
  );
});

重要なポイントcurrentUrlRefによる「トークン検証」が**競合状態(Race Condition)**対策として非常に重要です。

2.5 デバウンス戦略

const debouncedData = useDebouncedValue(resumeData, 400);

const pdfDocument = useMemo(
  () => (
    <ResumeDocument data={debouncedData} templateId={templateId}
      themeColor={themeColor} pdfBodyPx={pdfBodyPx} locale={locale} labels={labels} />
  ),
  [debouncedData, templateId, themeColor, pdfBodyPx, locale, labels]
);

設計思想:「連続入力」と「離散操作」を区別する。文字入力は連続的でデバウンス、テンプレート切替は離散的で即時応答。


三、核心的難題その2:中国語組版とフォントサイズ

3.1 @react-pdf/textkitにパッチ適用

@react-pdf/rendererの組版エンジンtextkitは欧文の単語区切りルールで設計されていますが、中国語にはスペースがありません。patch-packageで直接パッチ:

{
  "scripts": {
    "postinstall": "patch-package && node scripts/copy-pdf-worker.js"
  }
}
// CJK統合表意文字 + 全角句読点、各文字の後で改行可能
const CJK_RANGE = /[\u2E80-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF\u3000-\u303F]/;
// 行頭禁則・行末禁則の処理も必要

経験の共有patch-packageが最適解です——パッチは差分としてコミットされ、依存関係アップグレード時に競合を報告。すべてのフロントエンドチームが習得すべきエンジニアリングテクニックです。

3.2 フォントサブセット化:10MBから300KBへ

第1層:言語ごとのオンデマンド登録

export function ensurePdfFontsForRender(locale: Locale) {
  if (registeredLocales.has(locale)) return;
  if (locale === 'zh-CN' || locale === 'zh-TW') {
    Font.register({ family: 'NotoSansSC', fonts: [...subset fonts...] });
  } else if (locale === 'ja') {
    Font.register({ family: 'NotoSansJP', fonts: [...] });
  } else {
    Font.register({ family: 'Inter', fonts: [...] });
  }
  registeredLocales.add(locale);
}

第2層:文字セット切り詰め——fonttoolsでGB2312常用字セット + ラテン文字数字句読点でサブセット化。

pyftsubset NotoSansSC-Regular.otf --unicodes-file=gb2312.txt \
  --output-file=NotoSansSC-Regular.subset.ttf --flavor=woff2 --layout-features='*'

効果:16MB → 約300KB、98%圧縮。

第3層:OSS + CDN、1年間強力キャッシュ——Cache-Control: max-age=31536000, immutable


四、核心的難題その3:テンプレートシステムの抽象化

4.1 3層抽象化

┌─────────────────────────────────────────┐
│  Layer 3: テンプレートレジストリ          │
│  宣言的設定:id / カラム数 / ATS / tier  │
├─────────────────────────────────────────┤
│  Layer 2: テンプレートコンポーネント(A~G)│
│  「レイアウト」のみ担当                   │
├─────────────────────────────────────────┤
│  Layer 1: アトミックモジュール(13セクション)│
│  スタイルはすべてprops注入                │
└─────────────────────────────────────────┘

4.2 レジストリ駆動設計

export const PDF_TEMPLATES: PdfTemplateMeta[] = [
  { id: 'A', slug: 'classic-simple-resume-template', columns: 1, atsFriendly: true, tier: 'free' },
  { id: 'B', slug: 'two-column-professional-resume-template', columns: 2, atsFriendly: false, tier: 'pro' },
];

const pdfTemplateComponentById: Record<string, ComponentType<PdfTemplateComponentProps>> = {
  ATemplate, BTemplate, CTemplate, DTemplate, ETemplate, FTemplate, GTemplate,
};

新テンプレート追加のコスト:レイアウトコンポーネント + レジストリ1行 + サムネイル1枚のみ。

4.3 テーマ変数:フォントサイズ連動

export function buildTypography(pdfBodyPx: number) {
  const base = clamp(pdfBodyPx, 12, 20);
  return {
    body: base, small: round(base * 0.86), sectionTitle: round(base * 1.18),
    name: round(base * 2.1),
    lineHeight: base <= 13 ? 1.55 : base >= 18 ? 1.38 : 1.46,
    sectionGap: round(base * 1.2), itemGap: round(base * 0.7),
  };
}

五、バックエンド:Koaオニオンモデル

5.1 ミドルウェア組み立て順序がアーキテクチャそのもの

backend/app.js

const Koa = require("koa");
require("./config/env");
const app = new Koa();

app.proxy = process.env.TRUST_PROXY === "1";

const sessionSecret = String(process.env.SESSION_SECRET || "");
if (sessionSecret.length < 32) {
  throw new Error("SESSION_SECRET must contain at least 32 characters");
}

app.use(requestContext());   // ① リクエストID + 構造化ログ
app.use(errorHandler());     // ② 統一エラーキャプチャ

app.use(async (ctx, next) => {   // ③ セキュリティレスポンスヘッダー
  ctx.set("X-Content-Type-Options", "nosniff");
  ctx.set("X-Frame-Options", "DENY");
  ctx.set("Referrer-Policy", "no-referrer");
  if (process.env.NODE_ENV === "production" && ctx.secure) {
    ctx.set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload");
  }
  if (ctx.path.startsWith("/private/") || ctx.path.startsWith("/public/user/")) {
    ctx.set("Cache-Control", "no-store");
  }
  await next();
});

app.use(globalLimiter);      // ④ グローバルIPレート制限 120 req/min
app.use(serve(path.join(__dirname, "public"), { maxage: 365 * 24 * 60 * 60 * 1000 }));

app.use(cors({               // ⑤ CORS厳格ホワイトリスト
  origin: (ctx) => {
    const origin = ctx.get("origin");
    if (!origin) return "";
    if (corsOrigins.length === 0) {
      return process.env.NODE_ENV === "production" ? "" : origin;
    }
    return corsOrigins.includes(origin) ? origin : "";
  },
  credentials: true,
}));

onerror(app);
app.use(bodyparser({         // ⑥ 差別化サイズ制限
  enableTypes: ["json", "form", "text"],
  jsonLimit: `${maxResumeContentBytes + 64 * 1024}b`,
  formLimit: "64kb", textLimit: "64kb",
}));
app.use(json());
app.use(csrfProtection());   // ⑦ CSRF 3重検証

app.keys = [sessionSecret];
app.use(session({            // ⑧ Session設定
  key: "beautyresume.sid", httpOnly: true, signed: true, sameSite: "lax",
  secure: process.env.SESSION_COOKIE_SECURE === "1" || process.env.NODE_ENV === "production",
  genid: () => crypto.randomUUID(), maxAge: 7 * 24 * 60 * 60 * 1000,
  renew: true, overwrite: true,
}, app));

app.use(async (ctx, next) => {  // ⑧ 認証ゲートウェイ
  if (ctx.path.startsWith("/private")) {
    await requireLogin(ctx, next);
    return;
  }
  await next();
});

app.use(index.routes(), index.allowedMethods());  // ⑨ ビジネスルート
app.use(require("./middleware/jsonContentType")()); // ⑩ JSON強制(ルートの「後」)

jsonContentTypeがルートの後ろにマウントされている理由——オニオンモデルの復路フェーズを活用:

module.exports = () => async (ctx, next) => {
  await next();
  if (ctx.path.startsWith('/api') && ctx.type !== 'application/json') {
    ctx.type = 'application/json';
  }
};

これこそがオニオンモデルの真髄です——同じミドルウェアが「リクエスト進入」と「レスポンス返却」の両方で仕事ができる。

5.2 なぜJWTではなくSessionなのか

核心は**sessionVersionフィールドによる「全端末強制ログアウト」**:

async function loadCurrentUser(ctx) {
  const userId = ctx.session && ctx.session.userId;
  if (!userId) return null;
  const user = await User.findByPk(userId);
  if (!user || user.status !== 'active') { ctx.session = null; return null; }
  if (Number(ctx.session.sessionVersion) !== Number(user.sessionVersion)) {
    ctx.session = null;
    return null;
  }
  return user;
}

ユーザーがパスワード変更などを行うとsessionVersion++。全デバイス上の全セッションが即座に無効化されます。

5.3 履歴書共有リンク:ID列挙防止

function generateResumeSlug() {
  return crypto.randomBytes(16).toString('base64url');  // 22文字、~128ビットエントロピー
}

function maskContact(value, type) {
  if (!value) return '';
  if (type === 'phone') return value.replace(/^(\d{3})\d{4}(\d{4})$/, '$1****$2');
  if (type === 'email') {
    const [name, domain] = value.split('@');
    if (!domain) return value;
    return `${name.slice(0, Math.min(2, name.length))}${'*'.repeat(Math.max(1, name.length - 2))}@${domain}`;
  }
  return value;
}

さらに共有ページにX-Robots-Tag: noindex, nofollow。3重防御:列挙不可能 + コンテンツマスキング + インデックス禁止


六、SEOエンジニアリング

6.1 シャードインデックス

<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap><loc>https://beautyresume.com/sitemap-core.xml</loc></sitemap>
  <sitemap><loc>https://beautyresume.com/sitemap-templates.xml</loc></sitemap>
  <sitemap><loc>https://beautyresume.com/sitemap-articles.xml</loc></sitemap>
</sitemapindex>

6.2 hreflang完全クロス自動生成

const LOCALES = ['zh-CN', 'zh-TW', 'en', 'ja'];
const DEFAULT_LOCALE = 'zh-CN';

function buildAlternates(pathWithoutLocale) {
  const links = LOCALES.map(
    (l) => `<xhtml:link rel="alternate" hreflang="${l}" href="${SITE_ORIGIN}/${l}${pathWithoutLocale}" />`
  );
  links.push(`<xhtml:link rel="alternate" hreflang="x-default" href="${SITE_ORIGIN}/${DEFAULT_LOCALE}${pathWithoutLocale}" />`);
  return links.join('\n');
}

sitemap生成をビルドプロセスに組み込み:"build": "node scripts/generate-sitemap.js && next build"


七、デプロイ:standalone出力で80%削減

// next.config.js
module.exports = { output: 'standalone' };
方式 サイズ
完全node_modules + .next ~1.2 GB
standalone出力 ~180 MB

注意.next/staticpublicはstandaloneに自動的には含まれません。

PM2クラスター + グレースフルシャットダウン:

pm2 start bin/www -i max --name beautyresume-api
function shutdown(signal) {
  server.close(async () => { await sequelize.close(); process.exit(0); });
  setTimeout(() => process.exit(1), 15000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));

八、ハマったポイントと経験のまとめ

8.1 React 19でのPDFレンダリング

@react-pdf/rendererは独自のreconcilerを持ちます。React 19のStrict ModeではusePDFupdateInstanceが2回呼ばれる可能性があります。currentUrlRefトークン検証 + URL.revokeObjectURL()によるクリーンアップで対応。

8.2 TiptapはStarterKitを使わない

// ❌ 全部入り
"@tiptap/starter-kit": "^3.20.4"
// ✅ オンデマンド
"@tiptap/extension-bold": "^3.20.4",
"@tiptap/extension-document": "^3.20.4",
"@tiptap/extension-paragraph": "^3.20.4",
"@tiptap/extension-text": "^3.20.4",
"@tiptap/extension-hard-break": "^3.20.4",
"@tiptap/extension-list": "^3.20.4"

StarterKitは20以上の拡張をバンドルしますが、履歴書では不要。チャンクサイズ約40%削減。機能が少ない = ユーザーが変なレイアウトを作れない。

8.3 Redux細粒度購読

// ❌ 全フィールドの変更で再レンダリング
const resume = useSelector((s) => s.resume);
// ✅ 必要なスライスのみ購読
const workList = useSelector((s) => s.resume.work.list, shallowEqual);
// ✅ 派生データをcreateSelectorでメモ化
const selectVisibleSections = createSelector(
  [(s) => s.resume.sections, (s) => s.resume.sectionOrder],
  (sections, order) => order.filter((id) => sections[id]?.visible)
);

8.4 セキュリティチェックリスト

項目 対策
パスワード bcrypt, cost ≥ 10, 絶対に平文/MD5禁止
セッション httpOnly + signed + sameSite=lax + 本番secure強制
CSRF Origin / Referer / Sec-Fetch-Site 3重検証
レート制限 グローバルIP 120/min、ログイン/キャプチャは別途厳格化
SQLインジェクション 全Sequelizeパラメータ化クエリ、文字列連結禁止
XSS リッチテキストはsanitize-htmlホワイトリストでフィルタ
認可 全リソース操作でresource.userId === ctx.state.user.idを検証
ログ 機密フィールドはLOG_HASH_SECRETでハッシュ化後に保存
秘密鍵 起動時に長さ≥32を検証、非準拠なら起動拒否
列挙防止 公開リソースはすべて暗号的ランダムSlug

九、最後に

プロジェクト全体を振り返って得られた、移転可能なエンジニアリング判断

  1. 一貫性はパフォーマンスに優先する。 プレビューとエクスポートで同一コードを共有することは、アーキテクチャレベルで「WYSINWYG」系の全バグを排除する。
  2. ステートレスは銀の弾丸ではない。 ビジネスが「取り消し可能なログイン状態」を必要とする場合、Session + バージョン番号が正解。
  3. セキュリティをアーキテクチャの制約に。 正しいことが自動的に起こるようにする。
  4. サードパーティのバグにはpatch-packageがforkより優位。 保守コストほぼゼロ。
  5. 制約は良いデザイン。 Tiptapは太字とリストだけ、ユーザーはむしろ醜い履歴書を作れない。

関連リンク

この記事がお役に立てば、いいね・ブックマークをお願いします。技術的な質問はコメント欄でお待ちしています。

転載の際は出典を明記してください。記事内のコードはすべて実際の本番プロジェクトのものです。

#Next.js#React 19#Koa2#PDF实时预览#全栈#简历编辑器#SEO#多语言#架构设计