2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ポートフォリオサイトを開設しました!

2
Posted at

はじめに

初めましての人もそうでない人もこんにちは!
この記事が3月に投稿されていることを信じて書いております!(執筆開始日2月22日)

この記事を出してから1ヶ月後に私は社会人になります
すっごく働くのが不安です...
それでもなんとかなぁ〜れ~の精神でなんとかしていきますw

さてこれを見ているみなさんはポートフォリオサイトを作ってvercelかなんかで公開していますでしょうか?

どの程度の人数が自分用のポートフォリオサイトを作成しているか定かではありませんが一定数私は見かけます
用途としては多分、転職用とか自分をもっと知って欲しいとかそんなところでしょうか?

なにか憧れを感じませんか?
自分は感じます!!

そこで今回は社会人になるエンジニアの最初の一歩目として自分用のポートフォリオサイトを作ってみようと思います!
それプラス内定者インターンで覚えた知識を総動員して工夫しちゃいたいと思います!

今回使う技術

フロントエンド・バックエンド

  • Next.js
  • TypeScript
  • Tailwind CSS

取得データ

  • microCMS — 所属会社名、資格、個人プロジェクト、スキルなど諸々を入れるヘッドレスCMS
  • Qiita API — これまで投稿した技術記事を取得・表示

表示させたい情報

ヒーローセクション

サイトを開いて最初に見る箇所

  • 挨拶
  • 名前
  • 投稿した記事の数
  • 資格の数
  • 技術スタック

経歴セクション

  • 大学名、会社名
  • 学部学科、部署名
  • 軽く何をしたのか
  • 会社名をクリックすると詳細なやってきたことが書かれている

資格・認定セクション

  • 取得した資格や認定をもらったことを記載

プロダクトセクション

  • 個人開発で公開されているプロダクトを表示
  • ソースコード、実際のサービスリンクに飛ぶようにする

テックブログセクション

  • Qiitaから最新の記事3件を取得し表示する
  • 全て見たいならQiitaアカウントへ遷移させる

その他

  • 見る時期によって異なるデザインを表示(主に色)

この「季節で色が変わる」がこだわりポイントです!

アーキテクチャ

ポートフォリオサイトとはいえ、内定者インターンで学んだ設計を活かしたかったのでちゃんとレイヤーを分けています

microCMS / Qiita API
↓
src/lib//dto.ts              # APIレスポンスの生の型定義
↓
src/lib//converters.ts       # DTOをアプリ内型に変換する関数
↓
src/factory/microcms/〇〇.ts     # converterを呼ぶfetch関数群
↓
src/app/page.tsx              # サーバーコンポーネントで全データを並列fetch
↓
src/components/sections/.tsx # 各セクションのUIコンポーネント

なぜDTO層とアプリ型を分けるの?

microCMSのAPIレスポンスは snake_case だったりしますし、日付も ISO 8601 形式で返ってきます
でもcamelCase を使いたいし、日付も YYYY.MM みたいに見やすくしたいですよね

そこで 生データアプリ内型 を分離して、その間を converters が翻訳してくれます

例えばmicroCMSの生の型はこんな感じです

src/lib/microcms/dto.ts
export interface ProjectDTO {
    id: string;
    title: string;
    description: string;
    tech_stack?: string[];    // ← snake_case
    demo_url?: string;        // ← snake_case
    github_url?: string;      // ← snake_case
    image?: { url: string };
    publishedAt: string;
    createdAt: string;
    updatedAt: string;
}

export interface AffiliationDTO {
    id: string;
    name: string;
    affiliation: string;
    overview: string;
    achievements: string;
    startDate: number;
    endDate?: number;
    publishedAt: string;
    createdAt: string;
    updatedAt: string;
}

export interface CertificationDTO {
    id: string;
    name: string;
    date: string;
    publishedAt: string;
    createdAt: string;
    updatedAt: string;
}

これをアプリで使いやすい形に変換するのがconverterです

converter.ts
export function toProject(dto: ProjectDTO): Project {
    return {
        id: dto.id,
        title: dto.title,
        description: dto.description,
        techStack: dto.tech_stack ?? [],   // snake_case → camelCase + デフォルト値
        demoUrl: dto.demo_url,
        githubUrl: dto.github_url,
        imageUrl: dto.image?.url,
    }
}

function formatISOToYYYYMM(isoDate: string): string {
    const utcMs = new Date(isoDate).getTime()
    const jstDate = new Date(utcMs + 9 * 60 * 60 * 1000)  // JSTに変換
    const year = jstDate.getUTCFullYear()
    const month = String(jstDate.getUTCMonth() + 1).padStart(2, '0')
    return `${year}.${month}`
}

export function toCertification(dto: CertificationDTO): Certification {
    return {
        id: dto.id,
        name: dto.name,
        date: formatISOToYYYYMM(dto.date), 
    }
}

function formatYYYYMM(yyyymm: number): string {
    const year = Math.floor(yyyymm / 100)
    const month = yyyymm % 100
    return `${year}.${String(month).padStart(2, '0')}`
}

export function formatDateRange(startDate: number, endDate?: number): string {
    const start = formatYYYYMM(startDate)
    const end = endDate ? formatYYYYMM(endDate) : 'Present'
    return `${start} - ${end}`
}

export function toAffiliation(dto: AffiliationDTO): Affiliation {
    return {
        id: dto.id,
        name: dto.name,
        affiliation: dto.affiliation,
        overview: dto.overview,
        achievements: dto.achievements,
        startDate: dto.startDate,
        endDate: dto.endDate,
        period: formatDateRange(dto.startDate, dto.endDate),
    }
}
skill.ts
import { client } from "@/lib/microcms/client";
import { SkillDTO } from '@/lib/microcms/dto';
import { toSkill } from '@/lib/microcms/converters';
import { Skill } from '@/types/skill';

export async function fetchSkills(): Promise<Skill[]> {
    try {
        const response = await client.get<{contents: SkillDTO[] }>({
            endpoint: 'skills',
            queries: {
                orders: '-publishedAt',
            }
        })
        return response.contents.map(toSkill)
    } catch(error) {
        console.error(error)
        return []
    }
}

作ってみよう!

page.tsx ― 全データの並列fetch

app/page.tsx
import { fetchProjects } from "@/factory/microcms/project"
import { fetchSkills } from "@/factory/microcms/skill"
import { fetchProfile } from "@/factory/microcms/profile"
import { fetchCertifications } from "@/factory/microcms/certification"
import { fetchQiitaArticles } from "@/lib/qiita/client"
import { fetchAffiliations } from "@/factory/microcms/affiliation"
// ... コンポーネントのimportは省略

export const revalidate = 1209600;  // 2週間のISR

export default async function Home() {
  const [ skills, projects, profiles, qiita, certifications, affiliations ] = await Promise.all([
    fetchSkills(),
    fetchProjects(),
    fetchProfile(),
    fetchQiitaArticles(100),
    fetchCertifications(),
    fetchAffiliations()
  ])

  const ArticleCount: number = qiita.length
  const CertificationCount: number = certifications.length

  return (
    <>
      <ContentsWrapper>
        <Hero
          profile={profiles}
          qiita={ArticleCount}
          certifications={CertificationCount}
          skills={skills}
        />
        <hr className="border-section-divider my-4" />

        <section id="about" className="scroll-mt-16 py-12">
          <AboutMe affiliations={affiliations} />
        </section>

        <hr className="border-section-divider my-4" />

        <section id="certifications" className="scroll-mt-16 py-12">
          <Certifications certifications={certifications} />
        </section>

        <hr className="border-section-divider my-4" />

        <section id="projects" className="scroll-mt-16 py-12">
          <Projects projects={projects} />
        </section>

        <hr className="border-section-divider my-4" />

        <section id="blog" className="scroll-mt-16 py-12">
          <Blog qiita={qiita.slice(0, 3)} qiitaURL={profiles[0].QiitaURL} />
        </section>
      </ContentsWrapper>
      <Footer profile={profiles[0]} />
    </>
  )
}

revalidate = 1209600 で2週間のISR(Incremental Static Regeneration)を設定しています
コンテンツの更新はmicroCMS上で行えばいいので、このくらいの頻度で十分です

ヒーローセクション
サイトを開いて最初に目に入るところです
名前の挨拶、Qiita記事数、資格数、技術スタックを表示します

Hero.tsx
import { Profile } from "@/types/profile"
import { Skill } from "@/types/skill"

interface Prop {
    profile: Profile[]
    skills: Skill[]
    qiita: number
    certifications: number
}

export function Hero ({profile, skills, qiita, certifications}: Prop) {
    return (
        <div className="py-20">
            <h1 className="text-5xl font-bold text-center">
                こんにちは、{profile[0].name}です
            </h1>
            <ul className="flex justify-center gap-16 mt-16">
                <li>
                    <div className="flex flex-col items-center">
                        <h2 className="text-3xl font-bold text-accent">{qiita}</h2>
                        <p className="text-muted text-sm mt-1">Qiita記事数</p>
                    </div>
                </li>
                <li>
                    <div className="flex flex-col items-center">
                        <h2 className="text-3xl font-bold text-accent">{certifications}</h2>
                        <p className="text-muted text-sm mt-1">資格数</p>
                    </div>
                </li>
            </ul>
            <div className="flex flex-col items-center mt-16 gap-4">
                <p className="text-muted">主な技術スタック</p>
                <ul className="flex flex-wrap justify-center gap-3">
                    {skills.map((skill) => (
                        <li key={skill.id}>
                            <div className="flex items-center justify-center px-4 py-2 rounded-full border border-accent-border bg-accent-light">
                                <p className="text-sm whitespace-nowrap">{skill.name}</p>
                            </div>
                        </li>
                    ))}
                </ul>
            </div>
        </div>
    )
}

経歴セクション
ここは use client でクライアントコンポーネントにしています
会社名をクリックすると実績の詳細が展開される仕組みです

季節テーマ
ここが今回の目玉機能です!
アクセスした月によってサイト全体の配色が変わります

季節 月 テーマカラー イメージ
冬 1〜3月 澄んだ水色 冬の空気感
春 4〜6月 桜ピンク 桜の季節
夏 7〜9月 新緑 木々の緑
秋 10〜12月 紅葉の朱 紅葉

theme.ts
export type Season = 'spring' | 'summer' | 'autumn' | 'winter'

export function getSeason(month: number): Season {
  if (month >= 4 && month <= 6) return 'spring'
  if (month >= 7 && month <= 9) return 'summer'
  if (month >= 10 && month <= 12) return 'autumn'
  return 'winter'
}

これをクライアントコンポーネントの ThemeProvider で呼び出して、 タグに data-season 属性をセットします

ThemeProvider.tsx
'use client'

import { useEffect } from 'react'
import { getSeason } from '@/lib/theme'

export default function ThemeProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    const now = new Date()
    const season = getSeason(now.getMonth() + 1)
    document.documentElement.setAttribute('data-season', season)
  }, [])

  return <>{children}</>
}

layout.tsx でアプリ全体をラップします

layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
        <ThemeProvider>
          <Header/>
          <main className="pt-16">
            {children}
          </main>
        </ThemeProvider>
      </body>
    </html>
  );
}

CSSカスタムプロパティで季節ごとの配色を定義
globals.css で data-season 属性に応じたCSS変数を定義します

globals.css
@import "tailwindcss";

/* === デフォルト(冬)=== */
:root {
  --bg: #f5f5f5;
  --fg: #1a1a1a;
  --accent: #6b7280;
  --accent-light: #e8e8ec;
  --accent-border: #b0b0b8;
  --card-bg: #ffffff;
  --card-border: #d4d4d8;
  --muted: #6b7280;
  --section-divider: #d4d4d8;
  --header-bg: rgba(245, 245, 245, 0.75);
}

/* === 春(4-6月): 桜ピンク === */
[data-season="spring"] {
  --bg: #fff0f3;
  --fg: #1a1a1a;
  --accent: #c75b7a;
  --accent-light: #fcd6df;
  --accent-border: #e8a0b4;
  --card-bg: #fff8fa;
  --card-border: #f0b8c8;
  --muted: #996b7a;
  --section-divider: #f0b8c8;
  --header-bg: rgba(255, 240, 243, 0.75);
}

/* === 夏(7-9月): 新緑 === */
[data-season="summer"] {
  --bg: #f0f8f0;
  --fg: #1a1a1a;
  --accent: #4a8a5a;
  --accent-light: #d0e8d4;
  --accent-border: #90c498;
  --card-bg: #f6faf6;
  --card-border: #a8d4b0;
  --muted: #5a7a60;
  --section-divider: #a8d4b0;
  --header-bg: rgba(240, 248, 240, 0.75);
}

/* === 秋(10-12月): 紅葉の朱 === */
[data-season="autumn"] {
  --bg: #fef4ee;
  --fg: #1a1a1a;
  --accent: #b86840;
  --accent-light: #f4d8c4;
  --accent-border: #daa880;
  --card-bg: #fef9f5;
  --card-border: #e4c0a0;
  --muted: #8a6040;
  --section-divider: #e4c0a0;
  --header-bg: rgba(254, 244, 238, 0.75);
}

/* === 冬(1-3月): 澄んだ水色 === */
[data-season="winter"] {
  --bg: #eef6fb;
  --fg: #1a1a1a;
  --accent: #3a8aaa;
  --accent-light: #c8e4f0;
  --accent-border: #88c4da;
  --card-bg: #f6fafd;
  --card-border: #a0d0e4;
  --muted: #4a7a8e;
  --section-divider: #a0d0e4;
  --header-bg: rgba(238, 246, 251, 0.75);
}

@theme inline {
  --color-background: var(--bg);
  --color-foreground: var(--fg);
  --color-accent: var(--accent);
  --color-accent-light: var(--accent-light);
  --color-accent-border: var(--accent-border);
  --color-card-bg: var(--card-bg);
  --color-card-border: var(--card-border);
  --color-muted: var(--muted);
  --color-section-divider: var(--section-divider);
}

body {
  background: var(--bg);
  color: var(--fg);
  transition: background-color 0.3s ease, color 0.3s ease;
}

body に transition をつけているのでテーマ切り替え時に滑らかに色が変わります

ちゃんと動いてくれていますね!
季節テーマも反映されていて、今は冬なので澄んだ水色の配色になっています

デプロイ後

おわりに

今回は新卒エンジニアの一歩目としてポートフォリオサイトを作りました

こだわったポイントをまとめると

DTO ↔ アプリ型の分離: APIレスポンスとUI側の型を分けてconverter(純粋関数)でつなぐ
factory層のエラーハンドリング: APIが落ちてもサイト全体はクラッシュしない
Promise.allで並列fetch: データ取得の待ち時間を最小化
季節テーマシステム: CSS変数 × data属性 × Tailwind v4のカスタムテーマで実現

内定者インターンで学んだレイヤードアーキテクチャや考え方を個人開発に活かせたのは嬉しいです
正直ポートフォリオサイトにここまでの設計が必要かと言われたら微妙ですがw
でも自分の技術を見せる場所だからこそ、こだわりたいですよね

4月から社会人になりますが、この調子でどんどんアウトプットしていきたいと思います!

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

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?