VercelとNext.js静的エクスポートで実現する、サーバーコスト0の個人サイト運用術

はじめに

個人開発者や技術ブロガーにとって、ポートフォリオサイトやブログの運用コストは悩みの種です。「できるだけ費用をかけずに、でも表示速度や開発体験は妥協したくない…」そう考える方は多いのではないでしょうか。
この記事では、VercelのHobbyプランとNext.js 14の静的エクスポート機能(SSG)を組み合わせることで、サーバーコスト0円で高機能かつ高速な個人サイトを運用する方法を解説します。
実際にこの構成で運用している学習サイト StudyForge の知見を元に、具体的な技術スタックからデプロイ手順までを紹介します。

対象読者

  • 無料で自分のブログやポートフォリオサイトを持ちたい方
  • Next.jsやVercelを使ったモダンなWeb開発に興味がある方
  • Markdown/MDXでコンテンツを管理したい方

なぜ「コスト0円」が可能なのか?

VercelのHobbyプランは、非商用の個人プロジェクトに対して非常に寛大な無料枠を提供しています。

  • デプロイ: 無制限
  • 帯域幅: 100GB/月
  • ビルド時間: 6,000分/月

Next.jsの静的エクスポート(output: ‘export’)機能を使うと、サイト全体がHTML/CSS/JSファイルとして出力されます。これはサーバーサイドで動的な処理を必要としないため、VercelのCDNに配置するだけで超高速に配信できます。
この構成なら、個人のブログやポートフォリオサイト程度のアクセス数であれば、無料枠を超えることはほとんどありません

技術スタック

この構成の要となる技術スタックです。

  • フレームワーク: Next.js 14 (App Router)
  • 言語: TypeScript
  • スタイリング: Tailwind CSS
  • コンテンツ管理: MDX (Markdown with JSX)
  • Markdownパース:
    • gray-matter: frontmatter(記事のメタデータ)を解析
    • unified, remark-, rehype-: Markdown本文をHTMLに変換

構築手順

Step 1: Next.jsプロジェクトのセットアップ

まず、Next.jsプロジェクトを作成します。

1
npx create-next-app@latest my-static-site

npx create-next-app@latest my-static-site
次に、静的サイトとして出力するために next.config.mjs を編集します

1
2
3
4
5
6
7
8
9
10
/** @type {import('next').NextConfig} */
const nextConfig = {
// この一行を追加!
output: 'export',

// 必要に応じて他の設定も追加
// 例: trailingSlash: true,
};

export default nextConfig;

output: ‘export’ を指定することで、next build を実行した際に、Node.jsサーバーを必要としない完全に静的なファイル群が out ディレクトリに生成されるようになります。

Step 2: Markdown/MDXによるコンテンツ管理

記事やブログのコンテンツは、content/posts/ のようなディレクトリに .mdx ファイルとして管理するのがおすすめです。

1
2
3
4
5
6
7
8
9
---
title: "はじめての投稿"
publishedAt: "2026-03-22"
---

## こんにちは、世界!

これはMarkdownで書かれた最初の投稿です。
Next.jsとVercelで快適なブログ生活を始めましょう。

これらのファイルを読み込んでページを生成するために、サーバーサイドで動作するユーティリティ関数を用意します。
gray-matter でファイルの先頭にあるメタデータ(frontmatter)を、unified と remark/rehype プラグインを使ってMarkdown本文をHTMLに変換します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import rehypeStringify from 'rehype-stringify';

const postsDirectory = path.join(process.cwd(), 'content/posts');

export async function getPostData(slug) {
const fullPath = path.join(postsDirectory, `${slug}.mdx`);
const fileContents = fs.readFileSync(fullPath, 'utf8');

// gray-matterでメタデータをパース
const matterResult = matter(fileContents);

// remark/rehypeでMarkdownをHTMLに変換
const processedContent = await unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeStringify)
.process(matterResult.content);
const contentHtml = processedContent.toString();

return {
slug,
contentHtml,
...matterResult.data,
};
}

この関数を、Next.jsの動的セグメントページ(例: src/app/blog/[slug]/page.tsx)で使うことで、Markdownファイルに基づいた静的なページを生成できます。

Step 3: ビルド

プロジェクトが完成したら、ビルドコマンドを実行します。

1
npm run build

これにより、out ディレクトリに静的なHTML、CSS、JavaScriptファイルが生成されます。このディレクトリをWebサーバーに置くだけでサイトが公開できる状態です

Step 4: Vercelにデプロイ

いよいよデプロイです。手順は驚くほど簡単です。
1 . GitHubにリポジトリをプッシュ: 作成したプロジェクトをGitHubにプッシュします。
2 . Vercelにサインアップ: GitHubアカウントでVercelにサインアップします。
3 . プロジェクトをインポート:

  • Vercelのダッシュボードで「Add New…」->「Project」を選択。
  • 先ほどプッシュしたGitHubリポジトリをインポートします。

4 . デプロイ設定:

  • Framework Preset: Next.js が自動で選択されます。
  • Build and Output Settings: output: ‘export’ を設定している場合、Vercelが自動で検知し、出力ディレクトリを out に設定してくれます。

設定を確認して「Deploy」ボタンをクリックすれば、数分でビルドとデプロイが完了し、https://.vercel.app のようなURLでサイトが公開されます。
以降は、GitHubのmainブランチにプッシュするたびに、自動でビルドとデプロイが実行されます

まとめ

VercelとNext.jsの静的エクスポート機能を組み合わせることで、開発体験を損なうことなく、無料で高速な個人サイトを運用できます。

  • コスト: 0円(Hobbyプランの範囲内)
  • パフォーマンス: VercelのEdge Networkにより世界中から高速アクセス
  • 開発体験: Next.jsによるモダンな開発と、Git-pushによる簡単なデプロイフロー
    サーバー管理の手間から解放され、コンテンツ制作に集中できるこの構成を、ぜひ試してみてはいかがでしょうか。

バイブコーディングでLaravelをNextJSにリプレイスしてみた

以前にAWS資格試験問題集サイトをLaravelで作成したのですが、それをNextJSへのリプレイスをgithub copilotのagentモードとcursorを利用して行いました。

laravelのアプリケーションディレクトリの場所とリプレイスして欲しいnestJSディレクトリの場所を指定して。
後は、「appディレクトリはLaravel のフレームワークで問題出題のWEBアプリケーションが実装されています。これをnestJSディレクトリ内にNextJSへリプレイスをしてください。」と指示すると、叩き台程度のリプレイスを行ってくれました。

後は、各ページでエラーが出ていたのを、エラーメッセージを投げて修正して欲しいとか、デザインを見やすくして欲しいとか思ったことを特にプロンプトエンジニアリングとかを意識せずに適当に依頼しても、大体は期待通りに修正してくれました。
Next.js 15で変わった動的ルートパラメータの型エラー等がCursorでは解決できず、自分でコードを修正ましたが、数行程度です。

リプレイス前(Laravel)

リプレイス後(NextJS)

Eloquentのモック方法

MockreyでEloquentをモックする方法

動的プロパティへのアクセスをモック

$user->mail_address のような箇所をモックしたい場合、

shouldReceive(‘getAttribute’)->with({モックしたいプロパティ名})とする
(例)

1
2
3
$user->shouldReceive('getAttribute')
->with('mail_address')
->andReturn('foo@example.com');

issetで動的プロパティを評価している箇所をモック

isset($user->mail_address) の様な箇所をモックしたい場合、
shouldReceive(‘offsetExists’)->with({モックしたい評価されるプロパティ名})とする
(例)

1
$user->shouldReceive('offsetExists')->with('mail_address')->andReturnTrue();

リレーションの箇所をモック

1
2
3
4
public function profile()
{
return $this->belongsTo(profile::class);
}

のようなリレーションがUserモデルに定義されていて、$user->profile のような箇所をモックしたい場合。
動的プロパティと同様に、shouldReceive(‘getAttribute’)->with({モックしたいリレーション名})とする。

1
2
3
$user->shouldReceive('getAttribute')
->with('profile')
->andReturn(new Profile());

参考URL
https://github.com/unamu1229/test_laravel/commit/228069268bec9094c3238090ff2b82deab3d0a20

npm i -g @nestjs/cli が終わらない

nodenv で node のバージョン 22.5.0 をインストールして、npm i -g @nestjs/cli を実行してもエラーも吐かずにインストールがまったく完了しない状態になった、22.5.0をアンインストールして再度インストールして、npm i -g @nestjs/cliを実行してみても同様の状況。

そこで、nodenv で node のバージョン 20.15.1 をインストールして、npm i -g @nestjs/cli を実行すると一瞬でインストールが完了した。

nodeのバージョン22.5.0に対応していないのかも?npmさん、そういう時はエラーを吐いて欲しい。。。

TestFlight インストールできませんでした

App Store Connect の TestFlight から個人テスターを追加し、
個人テスターのスマホアプリの TestFlight から テストするアプリをインストールしようとすると、
「インストールできませんでした」と表示され、インストールできなかった。

原因は、App Store Connect の TestFlight の個人テスターのステータスの項目が、「利用可能なビルドなし」となっていた為。

App Store Connect の TestFlight の グループ の方にテスターを追加す
るとインストールできるようになった。

個人テスターのステータスが「利用可能なビルドなし」となっていた理由は、Appleの審査中だった為のようです、一日たって審査が終わると個人テスターの TestFlight からもインストールできるようになりました。

stoplight studioのMacアプリインストール

stoplight studioの公式サイトが更新されどこからMac版のアプリがダウンロードできるのか分からなくなっていた。

なので、brewでインストールする。

stoplight studioのインストールコマンド。

1
brew install --cask stoplight-studio

Xdebug コンディショナルブレークポイント

コンディショナルブレークポイント(Conditional Breakpoint)の機能を使えば、特定の条件の時のみ、ブレークポイントで止めることができる。

PHPStormの場合、ブレークポイントを設定してそれを右クリックするとConditionの入力エリアが出てくるので、そこにブレークポイントを止めたい場合の条件を設定する。

(例) $valueがbarの時のみブレークポイントで止める。

phpのissetとemptyとis_nullの違い

issetは返り値に対してチェックを行うとエラーになる。

1
2
これは、Fatal errorになる。
isset(bar());

emptyとis_nullは返り値に対してチェックができる。

1
2
3
これは、OK.
empty(bar());
is_null(bar());

なので、!empty(bar())とかがあって、isset(bar())のほうが否定(反転)がとれてリーダブルコードじゃんとか思って変更するとエラーになるw

Error: creating ELBv2 Listener 〇〇〇: UnsupportedCertificate: The certificate 〇〇〇 must have a fully-qualified domain name, a supported signature, and a supported key size.

terraform でACMをALBのリスナーに設定する下記のようなコードの一部があり、

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
〜〜〜

resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.alb.arn
port = "443"
protocol = "HTTPS"
certificate_arn = aws_acm_certificate.aws-qualification.arn
ssl_policy = "ELBSecurityPolicy-2016-08"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.ec2.arn
}
}

〜〜〜

terraform applyをすると、下記のようなエラーが表示されました。

1
2
3
4
5
6
7
8
Error: creating ELBv2 Listener 〇〇〇: UnsupportedCertificate: The certificate
〇〇〇 must have a fully-qualified domain name, a supported signature, and a
supported key size.
│ status code: 400, request id: 〇〇〇

│ with aws_lb_listener.https,
│ on alb.tf line 71, in resource "aws_lb_listener" "https":
│ 71: resource "aws_lb_listener" "https" {

原因はエラーメッセージの内容とは異なり、作成されたACMの検証が完了できていない為でした。
ですので、depends_onを利用して、先程のコードの一部を下記のように更新してALBのリスナーの作成がACMの検証済みとなることを待つようにしました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
〜〜〜

resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.alb.arn
port = "443"
protocol = "HTTPS"
certificate_arn = aws_acm_certificate.aws-qualification.arn
ssl_policy = "ELBSecurityPolicy-2016-08"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.ec2.arn
}

depends_on = [aws_acm_certificate_validation.standby]
}

resource "aws_acm_certificate_validation" "standby" {
certificate_arn = aws_acm_certificate.aws-qualification.arn
}

〜〜〜

そうして、terraform applyをすると、下記のようなメッセージが出力されACMの検証済みとなることを待つようになります。

1
2
3
aws_acm_certificate_validation.standby: Creating...
aws_acm_certificate_validation.standby: Still creating... [10s elapsed]
aws_acm_certificate_validation.standby: Still creating... [20s elapsed]

待っている間に、作成されたACMのドメイン、タイプ、CNAME名、CNAME値の内容で、Route53の対象のホストゾーンにレコードを作成する。
そうすると、検証が成功し、terraform applyの処理が最後まで進む。

Error: reading ELBv2 Load Balancer 〇〇〇 ValidationError: 〇〇〇 is not a valid load balancer ARN status code: 400, request id: 〇〇〇

terraform applyを行うと、このようなエラーがでました。

1
Error: reading ELBv2 Load Balancer (arn:aws:elasticloadbalancing:〇〇〇): ValidationError: 'arn:aws:elasticloadbalancing:〇〇〇' is not a valid load balancer ARN status code: 400, request id: 〇〇〇

原因は、私がterraformのデプロイ先のawsアカウントを、以前のものとは別の新しいawsアカウントに新たにデプロイを作成しようとしている為でした。
terraformが以前のアカウントで作成したelbを確認しようとしているけど、新しいawsアカウントにそのリソースが無いので、status code 400でエラーになっているのだと思います。
ですので、ローカルのterraform.tfstateを削除して、以前の構築した状態を削除すると新しいawsアカウントにデプロイができるようになりました。