Zenn
🥷

【Next.js 】ポートフォリオをアプデしました!

2025/02/16に公開

はじめに

初めまして!現在フリーランスでエンジニアをしているYusuke Kikutaです!
今回、今までに使っていたものだと今後の仕事にうまく活用できないなと思い、改めてポートフォリオを制作しました。今回はこの開発経験をもとにどのようにNext.jsを使用して簡単なフルスタックのwebアプリケーションを構築するかを執筆いたします。
https://portfolio-no-3.vercel.app/
↑Yusuke Kikutaのポートフォリオサイト

新規に追加機能を実装したもの

  • お問い合わせフォームの自動送信機能
    ユーザーが入力したお問い合わせ内容を、自分のメールアドレスに自動送信する API とバックエンドの実装。
  • Zenn の RSS を利用した記事一覧表示機能
    Zenn の RSS フィードから記事データを取得し、一覧表示セクションを作成。
  • 多言語対応(i18n)の実装
    固定 UI は英語リソースから取得し、Blogセクションは状態管理を使用して英文を表示する仕組み。

以上の追加機能を元のポートフォリオを基盤に実装し、制作いたしました。
ここからは、各機能の実装方法とその背景、選定理由についてお話ししていきます。


技術選定の背景

今回、ポートフォリオを Next.js で構築した理由は以下の通りです。

  1. フルスタック開発の容易さ
    Next.js はフロントエンドだけでなく、API ルートを活用することでバックエンドも一元管理できるため、問い合わせフォームの自動送信や RSS の取得など、フルスタックな機能を実装するのに非常に適しています。

  2. パフォーマンスと SEO の最適化
    サーバーサイドレンダリング (SSR) や静的生成 (SSG) を標準でサポートしており、パフォーマンス面でも優れているため、ユーザー体験の向上が期待できます。

  3. 最新の React 機能との互換性
    Next.js は最新の React 機能やフレームワークと連携しやすく、また Vercel との統合がスムーズなため、デプロイや運用面でもメリットが大きいです。

  4. 柔軟なプラグインとエコシステム
    reCAPTCHA v3、i18n、Framer Motion などのライブラリとの相性もよく、これらを組み合わせることで、洗練された UI/UX の実現が可能です。


ヒーローセクションの SVG ロゴアニメーション

僕自身ヒーローセクションにはこだわりを持っていて、ここをいかに凝るかで最後まで僕のサイトを読んでくれるかどうかが決まる、デザインの上では最重要なものと考えています。
今回は筆記体のロゴを線画するアニメーションを導入したく、SVGを使用して実装しました。
ここでは、Figma で作成した SVG ロゴをアニメーションさせています。以下は、SVG ロゴのパスを使ったシンプルなアニメーションの実装例です。


ヒーロセクション

// components/HandwritingSVG.js
import React from 'react';
import { motion } from 'framer-motion';

const svgVariants = {
  hidden: { opacity: 0 },
  visible: { opacity: 1, transition: { duration: 1 } }
};

const pathVariants = {
  hidden: { pathLength: 0, opacity: 0 },
  visible: {
    pathLength: 1,
    opacity: 1,
    transition: { duration: 2, ease: 'easeInOut' }
  }
};

const HandwritingSVG = () => {
  return (
    <motion.svg
      variants={svgVariants}
      initial="hidden"
      animate="visible"
      xmlns="http://www.w3.org/2000/svg"
      viewBox="0 0 400 100"
      width="400"
      height="100"
    >
      <motion.path
        variants={pathVariants}
        d="M40.992 11.176C42.144 11.176 43.04 11.464 43.68 12.04C44.384 12.616 44.832 13.576 45.024 14.92C45.216 16.264 45.12 18.024 44.736 20.2..."
        stroke="#000"
        strokeWidth="2"
        fill="transparent"
      />
    </motion.svg>
  );
};

export default HandwritingSVG;

このコードでは、Framer Motion を使い、SVG パスの描画(pathLength)を制御することで、ロゴが手書き風に描かれるアニメーションを実現しています。


RSS での Zenn 記事取得

次にRSSでのZennの記事取得についてです。今回のフルスタックweb appを作成するきっかけの一つでもあります。
背景としては、自分自身アウトプットをZennで行うことが多く、自分が参画したらどのようにプロジェクトに貢献できるかというものをわかりやすく提示できるもので、既存のものではZennのリンク埋め込みだけを行なっていたので改めて一覧表示させられるように設計いたしました。

Blogセクションの実装画面

ワークフロー図

以下は、RSS 取得のワークフローを示した Mermaid 図です。

コード例(getStaticProps 内での実装)

// pages/index.js(抜粋)
export async function getStaticProps() {
  try {
    const rssUrl = "https://zenn.dev/yusukekikuta/feed";
    const res = await fetch(rssUrl);
    if (!res.ok) {
      throw new Error(`RSSフィードの取得に失敗しました: ${res.status}`);
    }
    const xmlData = await res.text();
    const parsed = await parseStringPromise(xmlData, { explicitArray: false });
    let items = [];
    if (parsed.rss && parsed.rss.channel && parsed.rss.channel.item) {
      items = parsed.rss.channel.item;
    } else if (parsed.feed && parsed.feed.entry) {
      items = parsed.feed.entry;
    }
    if (!items) items = [];
    else if (!Array.isArray(items)) items = [items];

    const articles = items.map((item) => {
      const title = item.title;
      const link = typeof item.link === 'object' ? item.link._ || item.link : item.link;
      const summary = item.description || item.summary || '';
      const pubDate = item.pubDate || item.published || item.updated || '';
      let slug = '';
      if (link) {
        const parts = link.split('/');
        slug = parts.pop() || parts.pop();
      }
      return { title, link, summary, pubDate, slug };
    });
    return {
      props: { articles },
      revalidate: 60,
    };
  } catch (error) {
    console.error("Error in getStaticProps:", error);
    return { props: { articles: [] }, revalidate: 60 };
  }
}

このコードにより、ビルド時または再生成時に Zenn の RSS から記事データを取得し、<Blog> コンポーネントに渡すことができます。
また、現在はRSSの内容を自動翻訳すると運用コストがかかってしまうことを懸念し、画像のような英語版の表示を行なっています。今後Mediumなどに記事を掲載したらこの機能をUpdateしたいなと思っています。

英語版での実装画面


お問い合わせフォームの実装

今回はフリーランスとして業務委託の面談で使用することを踏まえ、フォーム実装に注力しました。
また自分のアドレスに自動送信されるのでスパム対策として、reCAPTCHA v3の導入を行いました。
お問い合わせフォームでは、ユーザーが入力した内容をnodemailerを使用して自分のメールアドレスに自動送信するため、以下のような通信フローで実装しています。

フォームのUI

ワークフロー図

API ルートのコード例

// pages/api/contact.js
import nodemailer from 'nodemailer';

const transporter = nodemailer.createTransport({
  host: process.env.EMAIL_HOST,
  port: Number(process.env.EMAIL_PORT),
  secure: process.env.EMAIL_SECURE === 'true',
  auth: {
    user: process.env.EMAIL_USER,
    pass: process.env.EMAIL_PASS,
  },
});

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ message: 'Method Not Allowed' });
  }
  const { name, email, message } = req.body;
  try {
    const mailOptions = {
      from: process.env.EMAIL_FROM || process.env.EMAIL_USER,
      to: 'yusukekikuta.05@gmail.com',
      subject: '企業様のお問い合わせ',
      text: `Name: ${name}\nEmail: ${email}\nMessage: ${message}`,
      html: `<p><strong>Name:</strong> ${name}</p>
             <p><strong>Email:</strong> ${email}</p>
             <p><strong>Message:</strong><br/>${message}</p>`,
    };
    const info = await transporter.sendMail(mailOptions);
    console.log('Email sent:', info);
    return res.status(200).json({ message: 'Email sent successfully' });
  } catch (error) {
    console.error('Error sending email:', error);
    return res.status(500).json({ message: 'Error sending email' });
  }
}

お問い合わせフォーム(コンポーネント)のコード例

以下のコードでは、フォームのバリデーション、reCAPTCHA、ローディングアニメーション、送信完了後のモーダル表示を実装しています。

// components/Contact.js
import React, { useState, useEffect } from 'react';
import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';
import { useTranslation } from 'react-i18next';

const Contact = () => {
  const { t } = useTranslation();
  const [formData, setFormData] = useState({ name: '', email: '', message: '' });
  const [loading, setLoading] = useState(false);
  const [showModal, setShowModal] = useState(false);
  const [countdown, setCountdown] = useState(10);
  const { executeRecaptcha } = useGoogleReCaptcha();

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));
  };

  const isValidEmail = (email) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (formData.name.length > 100) {
      alert(t('nameTooLong', 'お名前は100文字以内で入力してください。'));
      return;
    }
    if (!isValidEmail(formData.email)) {
      alert(t('emailInvalid', '正しいメールアドレスを入力してください。'));
      return;
    }
    if (formData.message.length > 1000) {
      alert(t('messageLengthError', 'お問い合わせ内容は1000文字以内で入力してください。'));
      return;
    }
    if (!executeRecaptcha) {
      alert(t('recaptchaUnavailable', 'reCAPTCHA が利用できません。後ほど再度お試しください。'));
      return;
    }
    const token = await executeRecaptcha('contact_form');
    setLoading(true);
    try {
      const res = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ ...formData, captchaToken: token }),
      });
      if (!res.ok) {
        throw new Error(t('sendFailed', 'メール送信に失敗しました'));
      }
      setFormData({ name: '', email: '', message: '' });
      setShowModal(true);
      setCountdown(10);
    } catch (error) {
      console.error('Error sending email:', error);
      alert(t('sendError', 'お問い合わせの送信に失敗しました。後ほど再度お試しください。'));
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    let timer;
    if (showModal) {
      timer = setInterval(() => {
        setCountdown(prev => {
          if (prev <= 1) {
            clearInterval(timer);
            // 送信完了後、モーダル表示と同時にトップページへ自動遷移
            window.location.href = '/';
            return 10;
          }
          return prev - 1;
        });
      }, 1000);
      document.body.style.overflow = 'hidden';
    } else {
      document.body.style.overflow = 'auto';
    }
    return () => clearInterval(timer);
  }, [showModal]);

  return (
    <section id="contact" className="contact-section">
      <h2 className="section-title">{t('contactTitle', 'Contact')}</h2>
      <p className="contact-subtitle">{t('contactSubtitle', 'お仕事のご連絡はこちらからお願いします。')}</p>
      <form onSubmit={handleSubmit} className="contact-form">
        <input type="text" name="name" placeholder={t('namePlaceholder', 'お名前')} required maxLength="100" value={formData.name} onChange={handleChange} />
        <input type="email" name="email" placeholder={t('emailPlaceholder', 'メールアドレス')} required pattern="^[^\s@]+@[^\s@]+\.[^\s@]+$" value={formData.email} onChange={handleChange} />
        <textarea name="message" placeholder={t('messagePlaceholder', 'お問い合わせ内容 (最大1000文字)')} maxLength="1000" required value={formData.message} onChange={handleChange}></textarea>
        <button type="submit" disabled={loading}>{t('submitButton', '送信')}</button>
      </form>

      {loading && (
        <div className="loading-overlay">
          <div className="spinner"></div>
          <p className="loading-text">{t('loadingText', 'Sending...')}</p>
        </div>
      )}

      {showModal && (
        <div className="modal-overlay">
          <div className="modal">
            <button className="modal-close" onClick={() => setShowModal(false)}>×</button>
            <p className="modal-message">
              {t('modalSuccess', 'お問い合わせが正常に送信されました。自動でトップページに戻ります。')}
            </p>
            <p className="modal-countdown">
              {countdown} {t('modalCountdown', '秒後に自動でトップページに戻ります。')}
            </p>
          </div>
        </div>
      )}

     
    </section>
  );
};

export default Contact;


モーダルの実装UI[英語表記版]
【実装のポイント】

  • フォームバリデーション
    名前、メール、メッセージの文字数や形式のチェックを行っています。
  • reCAPTCHA v3 の活用
    executeRecaptcha を用いてトークンを取得し、セキュリティを担保しています。
  • ローディング中のアニメーション
    モダンなモノトーンデザインのスピナーを CSS アニメーションで実装。
  • 送信完了時のモーダル
    モーダル表示後、カウントダウン終了時に自動でトップページへ遷移します。

i18n の翻訳リソース定義例

実は著者のYusuke Kikutaはひっそりと海外移住を夢見ている若人なので、英語圏のユーザーのニーズがあった時に対応できるように英語版の実装も行いました。
今回はi18nを使用してcontextsを作成し、英語の本文を定義しています。
またこれは余談ですがswicherを作動させた時のアニメーションがお気に入りですのでみなさんぜひ試してみてください。
今回の英語版実装に向けた翻訳リソースは以下のようになります。

// i18n.js
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';

const resources = {
  en: {
    translation: {
      sectionTitleAbout: "About Me",
      sectionTitleCareer: "Career",
      sectionTitleBlog: "Blogs",
      contactTitle: "Contact",
      contactSubtitle: "For business inquiries, please use the contact form.",
      namePlaceholder: "Your Name",
      emailPlaceholder: "Your Email",
      messagePlaceholder: "Your Message (Max 1000 characters)",
      submitButton: "Submit",
      nameTooLong: "Your name must be within 100 characters.",
      emailInvalid: "Please enter a valid email address.",
      messageLengthError: "Your message must be within 1000 characters.",
      recaptchaUnavailable: "reCAPTCHA is unavailable. Please try again later.",
      sendFailed: "Failed to send the email.",
      sendError: "Error sending your inquiry. Please try again later.",
      loadingText: "Sending...",
      modalSuccess: "Your inquiry was sent successfully. You will be redirected to the home page shortly.",
      modalCountdown: "seconds until redirection.",
      viewMore: "View More",
      noArticles: "No articles available."
      // その他必要なキーを追加
    }
  },
  ja: {
    translation: {
      sectionTitleAbout: "私について",
      sectionTitleCareer: "経歴",
      sectionTitleBlog: "ブログ",
      contactTitle: "お問い合わせ",
      contactSubtitle: "お仕事のご連絡はこちらからお願いします。",
      namePlaceholder: "お名前",
      emailPlaceholder: "メールアドレス",
      messagePlaceholder: "お問い合わせ内容 (最大1000文字)",
      submitButton: "送信",
      nameTooLong: "お名前は100文字以内で入力してください。",
      emailInvalid: "正しいメールアドレスを入力してください。",
      messageLengthError: "お問い合わせ内容は1000文字以内で入力してください。",
      recaptchaUnavailable: "reCAPTCHA が利用できません。後ほど再度お試しください。",
      sendFailed: "メール送信に失敗しました。",
      sendError: "お問い合わせの送信に失敗しました。後ほど再度お試しください。",
      loadingText: "送信中...",
      modalSuccess: "お問い合わせが正常に送信されました。自動でトップページに戻ります。",
      modalCountdown: "秒後に自動でトップページに戻ります。",
      viewMore: "もっと見る",
      noArticles: "記事がありません。"
      // その他必要なキーを追加
    }
  }
};

i18n
  .use(initReactI18next)
  .init({
    resources,
    lng: "ja", // 初期言語
    fallbackLng: "ja",
    interpolation: { escapeValue: false },
    react: { useSuspense: false },
    debug: false,
  });

export default i18n;

まとめ

本記事では、Next.js を使ったフルスタックポートフォリオの開発過程について、以下の主要機能の実装方法を詳しく解説しました。

  1. ヒーローセクションの SVG アニメーション
    Figma で作成した SVG パスを Framer Motion を用いてアニメーションさせ、インパクトのあるロゴ表示を実現しました。

  2. RSS での記事取得
    Zenn の RSS フィードから記事を取得し、getStaticProps でパースして Blog セクションに渡す仕組みを構築しました。

  3. お問い合わせフォームの実装
    reCAPTCHA v3 と nodemailer を活用し、問い合わせ内容を自動送信する API ルートを実装。また、送信時のローディングアニメーションと送信完了後のモーダル表示、さらにカウントダウン終了後に自動でトップページへ遷移する機能も実装しました。

  4. 多言語対応(i18n)の実装
    固定の UI テキストは英語リソースから取得し、状況に応じた翻訳キーを使用することで、ユーザーが言語を切り替えた際にスムーズなアニメーションとともに表示内容が更新される仕組みを実現しました。

今後の展望

  • Medium での英語記事投稿
    現在はBlogセクションを英語版で表示すると自動翻訳のAPI制限の運用コストの懸念から、実装予定というメッセージが表示されるようにしていますが、今後 Medium で記事投稿を開始した際には、RSS から取得する記事の翻訳機能や表示の切替え機能を充実させ、よりグローバルなユーザーにも対応できるようにする予定です。

  • レスポンシブデザインの設計
    現在はスマホで表示すると、PCで閲覧くださいとの警告が出るように設計しているので、スマートフォンでも対応可能なようにUpdateを行う予定です。


この記事に関してご質問や補足情報が必要な場合は、お気軽にお知らせください。これからも、フルスタック開発のノウハウを共有し、皆さんの開発の参考になれば幸いです。

GitHub リポジトリ

https://github.com/yusukekikuta0509/portfolio_no.3

Discussion

ログインするとコメントできます