はじめに

ブログや成果物を載せていきたいなーと思った時に いろんなサービスを横断的に使うのもいいですけど、 集約するのがいいなと思いましたのでポートフォリオサイトを構築してみることにしました。

ポートフォリオサイトにあるといい機能を洗い出すと、現時点では下記となります。

- 自己紹介 / Profile
- ブログ / Blog
- 成果物情報 / Products
- お問い合わせ / Contact
- プライバシーポリシー等

ブログではメディア媒体を扱いますし、何よりMarkdownなどで書きやすくしておくう必要があります。

今回はAstro + Cloudflareを使い構築していくことにします。

  • Blog / Products
    • [Astro] png,jpegなど画像をastro build実行時に webpに変換できる。
      毎回画像変換なんてやってられません。
    • [Astro] @astrojs/mdxで コンポーネントを埋め込むことができる。
      Markdownファイルで気軽に書くことができ、自分で関数を作り拡張できる。
  • Contact
    • [Cloudflare] お問合せ受付メールとサクッとメール連携できると楽でいい!

このポートフォリオサイトを構築する過程で学んだTipsをまとめました。

全体構成

src配下はざっくりこのように配置しました。

基本的にAstroベースでシンプル構成です。

src
├── components
│   ├── Blog.astro
│   ├── Contact.astro
│   ├── ContactCTA.astro
│   ├── Footer.astro
│   ├── Nav.astro
│   ├── Products.astro
│   ├── Profile.astro
│   ├── ShareButtons.astro
│   ├── TableOfContents.astro
│   └── mdx
│       ├── AffiliateLink.astro
│       ├── ExternalLink.astro
│       ├── LinkCard.astro
│       └── Video.astro
├── content
│   ├── blog
│   │   ├── 2026-04-13_astro_cloudflare-portfolio
│   │   │   ├── assets1.jpg
│   │   │   ├── cover.png
│   │   │   └── index.mdx
│   │   └── 2026-04-xx_xxxxxxxxx
│   │       ├── assets1.jpg
│   │       ├── cover.png
│   │       └── index.mdx
│   ├── config.ts
│   └── products
│       ├── Product1.mdx
│       └── Product2.mdx
├── layouts
│   └── Layout.astro
└── pages
    ├── api
    │   └── contact.ts
    ├── blog
    │   ├── [slug].astro
    │   └── index.astro
    ├── contact.astro
    ├── index.astro
    ├── privacy-policy.astro
    ├── products
    │   ├── [slug].astro
    │   └── index.astro
    └── rss.xml.ts

Astroは静的コンテンツとの相性はいい

詳しくは公式ページを見ていただければ幸いですが、
SEO面やメディアファイルの最適化が優秀です。

このページにあるカバー画像とフッター画像は1MB超えですが、 astro buildした際には WebPに変換されます。

ブログ/成果物ページを手軽に更新する仕組みにする

フォルダ構成でアセットを管理する

フォルダごとに記事をまとめると、画像・動画を同じ場所に置けてスッキリします。

src/content/blog/
└── 2026-04-13_astro_cloudflare-portfolio/
    ├── index.mdx   ← 記事本文
    ├── cover.png   ← カバー画像
    └── assets1.jpg ← カバー以外の画像

Content Collectionsを活用して、簡単に一覧ページに反映

Markdownファイルをコレクションとして管理することで、型安全なフロントマターが使えます。

import { defineCollection, z } from 'astro:content';

const blog = defineCollection({
  type: 'content',
  schema: ({ image }) =>
    z.object({
      title: z.string(),
      date: z.string(),
      tags: z.array(z.string()),
      excerpt: z.string(),
      emoji: z.string(),
      cover: image().optional(),
      draft: z.boolean().default(false),
    }),
});

const products = defineCollection({
  type: 'content',
  schema: z.object({
    name: z.string(),
    description: z.string(),
    emoji: z.string(),
    tags: z.array(z.string()),
    link: z.string().optional(),
    repo: z.string().optional(),
    status: z.enum(['Released', 'Beta', 'WIP']),
    draft: z.boolean().default(false),
    order: z.number().default(0),
  }),
});

export const collections = { blog, products };

これを各mdxの最初に追加するだけで、反映完了です。

---
title: Astro+CloudflareでポートフォリオサイトをゼロからつくるTips
date: '2026-04-13'
tags: ['Astro', 'Cloudflare']
excerpt: コンテンツ駆動型フレームワーク「Astro」を「Cloudflare」で構築して簡易ポートフォリオサイトを公開する!
emoji: 🖌️
cover: './cover.png'
draft: false
---

Draftは非公開に、記事順を並べる

Blogはじっくり内容を練って作っていきたいことも出てくると踏んで、 draft: trueであれば、dev環境だけで表示されるので、ブログ作成を捗らせることもできます。

---
export async function getStaticPaths() {
  const isDev = import.meta.env.DEV;
  const posts = await getCollection('blog', ({ data }) => isDev || !data.draft);
  // 日付降順(新しい順)に並べる
  const sorted = posts.sort(
    (a, b) => new Date(b.data.date).getTime() - new Date(a.data.date).getTime()
  );

  return sorted.map((post, index) => ({
    params: { slug: post.slug },
    props: {
      post,
      // より新しい記事(左 << )
      newer: index > 0
        ? { slug: sorted[index - 1].slug, title: sorted[index - 1].data.title, emoji: sorted[index - 1].data.emoji }
        : null,
      // より古い記事(右 >> )
      older: index < sorted.length - 1
        ? { slug: sorted[index + 1].slug, title: sorted[index + 1].data.title, emoji: sorted[index + 1].data.emoji }
        : null,
    },
  }));
}
---

リンクカード表示できるようにする

mdxのいいところはMarkdownに関数を埋め込むこともできるところです。

URLのリンクカードをBlogではよく見かけますよね。

もちろんただのMarkdownではないものなのですが、 関連ブログからどんな概要なのか察知できることは大事ですね。

サンプルコードも貼っておきます。

---
interface Props {
  href: string;
}
const { href } = Astro.props;

type OgData = {
  title: string;
  description: string;
  image: string;
  favicon: string;
  domain: string;
};

function extractMeta(html: string, patterns: RegExp[]): string {
  for (const re of patterns) {
    const m = html.match(re);
    if (m?.[1]) return m[1].trim();
  }
  return '';
}

let og: OgData = { title: href, description: '', image: '', favicon: '', domain: '' };

try {
  const url = new URL(href);
  og.domain = url.hostname;
  og.favicon = `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32`;

  const res = await fetch(href, {
    headers: { 'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1)' },
    signal: AbortSignal.timeout(5000),
  });
  const html = await res.text();

  og.title =
    extractMeta(html, [
      /<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i,
      /<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i,
      /<title[^>]*>([^<]+)<\/title>/i,
    ]) || href;

  og.description = extractMeta(html, [
    /<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i,
    /<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:description["']/i,
    /<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i,
    /<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i,
  ]);

  og.image = extractMeta(html, [
    /<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i,
    /<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i,
  ]);

  // 相対パスを絶対URLに変換
  if (og.image && !og.image.startsWith('http')) {
    og.image = new URL(og.image, href).toString();
  }
} catch {
  // フェッチ失敗時はURLのままフォールバック
}
---

<a href={href} class="link-card" target="_blank" rel="noopener noreferrer">
  <div class="link-card-body">
    <p class="link-card-title">{og.title}</p>
    {og.description && (
      <p class="link-card-desc">{og.description}</p>
    )}
    <div class="link-card-footer">
      <img src={og.favicon} alt="" width="16" height="16" class="link-card-favicon" loading="lazy" />
      <span class="link-card-domain">{og.domain}</span>
    </div>
  </div>
  {og.image && (
    <div class="link-card-thumb">
      <img src={og.image} alt={og.title} loading="lazy" />
    </div>
  )}
</a>

<style>
  .link-card {
    display: flex;
    align-items: stretch;
    border: 1.5px solid var(--cream-dark);
    border-radius: var(--radius);
    overflow: hidden;
    background: var(--cream);
    transition: border-color 0.2s, box-shadow 0.2s, transform 0.2s;
    text-decoration: none !important;
    margin: 1.25rem 0;
    min-height: 90px;
  }

  .link-card * {
    text-decoration: none !important;
  }

  .link-card img {
    max-width: none !important;
    margin: 0 !important;
    border-radius: 0 !important;
    display: block;
  }

  .link-card:hover {
    border-color: var(--choco-accent);
    box-shadow: var(--shadow);
    transform: translateY(-2px);
  }

  .link-card:visited .link-card-title {
    color: var(--text-muted);
  }

  .link-card:visited .link-card-domain {
    color: var(--choco-accent);
  }

  .link-card-body {
    flex: 1;
    min-width: 0;
    padding: 0.85rem 1rem;
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    justify-content: center;
  }

  .link-card-title {
    font-size: 0.925rem;
    font-weight: 800;
    color: var(--choco-dark);
    line-height: 1.4;
    overflow: hidden;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    margin: 0;
  }

  .link-card-desc {
    font-size: 0.8rem;
    color: var(--text-muted);
    line-height: 1.5;
    overflow: hidden;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    margin: 0;
  }

  .link-card-footer {
    display: flex;
    align-items: center;
    gap: 0.35rem;
    margin-top: 0.15rem;
  }

  .link-card-favicon {
    width: 16px;
    height: 16px;
    border-radius: 3px;
    object-fit: contain;
  }

  .link-card-domain {
    font-size: 0.75rem;
    color: var(--text-muted);
    font-weight: 600;
  }

  .link-card-thumb {
    flex-shrink: 0;
    width: 160px;
    background: var(--cream-mid);
    overflow: hidden;
  }

  .link-card-thumb img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
  }

  @media (max-width: 500px) {
    .link-card-thumb { width: 110px; }
  }
</style>

まとめ

AstroはSSGペースで今回のようなブログなどにはよくマッチしていました。

Content Collectionsを使えばブログ管理も型安全になります。

定期的にブログ更新しながら、拡張を加えていくのも面白みがありますね。

ぜひ試してみてください。

2020/11/20 山中湖にて