業種別LPサイト構築 - Nuxt 4 + サブドメイン方式で税務コンサルティングのランディングページを量産する
税務コンサルティング向けの業種別ランディングページを、1つの Nuxt 4 アプリケーションでサブドメインごとに出し分けるプロジェクト「tax-lp」の初日開発記録。ターゲット業種の戦略的選定から、localhost サブドメインでの業種切替、マークダウン記事の LP 統合、不動産 LP の専用デザイン、記事一覧・個別記事ページの実装、ページトランジションまで、一気に構築した。
プロジェクトの背景と目的
やりたいこと
税務コンサルティングの見込み客を業種ごとに獲得するためのランディングページを量産したい。記事を1本書いたら、それを複数の業種に展開し、サブドメインで運用する。
たとえば「確定申告の基礎」という記事を書いたら:
beauty.example.comでは美容室オーナー向けの文脈で表示fudosan.example.comでは不動産投資家向けの文脈で表示freelance.example.comではITフリーランス向けの文脈で表示
各サブドメインは専門特化した税理士事務所のサイトに見える。フッターに「対応業種一覧」を載せない(後述)ことで、その業種だけに集中しているように演出する。
技術スタック
| 技術 | バージョン | 用途 |
|---|---|---|
| Nuxt | 4 | フレームワーク |
| Vue | 3 | UIライブラリ |
| pnpm | - | パッケージマネージャー |
| Tailwind CSS | 4 | スタイリング |
| @nuxt/content | v3 | マークダウン記事管理 |
| @tailwindcss/typography | - | 記事本文のスタイリング |
最初は npm でセットアップを試みたが、Nuxt 4 の依存関係(oxc-parser の native binding)で失敗。pnpm に切り替えたら問題なくインストールできた。Nuxt 4 では pnpm が安定する。
ターゲット業種の戦略的選定
選定の判断軸
業種を選ぶにあたり、以下の4軸で評価した。
- 検索ボリューム: 「{業種} 税理士」の月間検索数
- 記事の展開しやすさ: 1記事を複数業種に使い回せるか
- 顧客単価: 顧問契約に繋がったときの LTV
- 差別化のしやすさ: 業種特有の税務論点があるか
Tier分類
Tier A(メインターゲット: 5業種)
| 業種 | サブドメイン | 選定理由 |
|---|---|---|
| 不動産 | fudosan | 減価償却・譲渡所得・法人化など税務論点が多い。顧客単価が高い |
| 美容室 | beauty | 既存クライアントがいる。開業支援・創業融資の提案がしやすい。AI時代にもなくならない業種 |
| ITフリーランス | freelance | 検索ボリュームが大きい。経費判断の相談が多く記事ネタが豊富 |
| クリエイター | creator | デザイナー・映像・ライターなど。著作権・海外取引の税務に特化できる |
| スタートアップ | startup | 資金調達・ストックオプション・R&D税制など専門性を出せる |
Tier B(次期展開: 3業種)
| 業種 | サブドメイン |
|---|---|
| 飲食店 | inshoku |
| 建設業 | kensetsu |
| EC事業者 | ec |
Tier C(将来展開: 3業種)
| 業種 | サブドメイン |
|---|---|
| 医療・クリニック | medical |
| 農業 | nogyou |
| アーティスト・芸能 | artist |
Tier D(検討中: 1業種)
| 業種 | サブドメイン |
|---|---|
| NPO・一般社団法人 | npo |
旧リストから外した業種
| 業種 | 外した理由 |
|---|---|
| 医師・歯科医師 | 参入障壁が高く、既存の税理士ネットワークが強い |
| 相続・資産承継 | 業種ではなくサービス分類。LPの切り口が異なる |
| ひとり社長・マイクロ法人 | 業種横断的で差別化しにくい |
フリーランスクリエイター枠の新設
開発終盤で「フリーランスクリエイター」の枠を新設した。既存の「ITフリーランス」と「クリエイター」の間に位置する層で、映像制作・Web制作・イラストなど技術系のフリーランスを対象とする。ITフリーランスがエンジニア寄りなのに対し、フリーランスクリエイターはデザイン・制作寄りの位置づけ。
プロジェクト初期化
Nuxt 4 プロジェクトのセットアップ
nuxi init のインタラクティブプロンプトが --template や --packageManager フラグをつけても待機状態になる問題があり、テンプレートを直接ダウンロードしてマージする方法で回避した。
# テンプレートをダウンロードしてマージ
npx giget nuxt nuxt4-template
# 必要なファイルを tax-lp にコピー
Nuxt 4 では app/ ディレクトリが標準。~ エイリアスは app/ を指す。
tax-lp/
├── app/
│ ├── app.vue
│ ├── components/
│ │ └── lp/
│ │ ├── shared/ # 共通コンポーネント
│ │ │ ├── Header.vue
│ │ │ ├── Footer.vue
│ │ │ ├── Article.vue
│ │ │ └── ...
│ │ ├── fudosan/ # 不動産専用
│ │ │ ├── FudosanLanding.vue
│ │ │ ├── FudosanHero.vue
│ │ │ └── ...
│ │ ├── beauty/ # 美容室専用
│ │ └── ...
│ ├── composables/
│ │ ├── useSubdomain.ts
│ │ └── useIndustryContent.ts
│ ├── config/
│ │ └── industries.ts # 業種設定(フォールバック用)
│ ├── layouts/
│ │ └── default.vue
│ └── pages/
│ ├── index.vue # 業種ディスパッチャー
│ ├── articles/
│ │ └── index.vue # 記事一覧
│ └── articles/
│ └── [slug].vue # 個別記事
├── content/
│ ├── beauty/
│ │ └── index.md
│ ├── fudosan/
│ │ └── index.md
│ ├── freelance/
│ │ └── index.md
│ └── ...
├── server/
│ └── middleware/
│ └── subdomain.ts # サブドメイン検出
├── modules/
│ └── subdomain-urls.ts # dev起動時にURL表示
├── content.config.ts
├── nuxt.config.ts
├── tailwind.config.ts
└── package.json
パッケージマネージャーの選択
# npm → 失敗(oxc-parser の native binding エラー)
npm install # Error: Cannot find module 'oxc-parser-...'
# pnpm → 成功
rm -rf node_modules package-lock.json
pnpm install # 問題なし
localhostサブドメインによる業種切替
サブドメイン検出ミドルウェア
server/middleware/subdomain.ts で、リクエストの Host ヘッダーからサブドメインを抽出し、イベントコンテキストに埋め込む。
// server/middleware/subdomain.ts
export default defineEventHandler((event) => {
const host = getRequestHeader(event, 'host') || ''
// beauty.localhost:3010 → "beauty"
// fudosan.localhost:3010 → "fudosan"
const match = host.match(/^([a-z]+)\.localhost/)
const subdomain = match?.[1] || 'beauty' // デフォルトは beauty
// コンテキストに埋め込み(composable から参照可能)
event.context.subdomain = subdomain
})
ローカルプレビュー URL
ポートを3010に固定し、サブドメインで業種を切り替える。localhost のサブドメインはDNS設定なしで 127.0.0.1 に解決されるため、hosts ファイルの編集は不要。
| URL | 業種 |
|---|---|
beauty.localhost:3010 | 美容室 |
freelance.localhost:3010 | ITフリーランス |
fudosan.localhost:3010 | 不動産 |
creator.localhost:3010 | クリエイター |
startup.localhost:3010 | スタートアップ |
// nuxt.config.ts
export default defineNuxtConfig({
devServer: {
port: 3010,
},
// ...
})
dev起動時のURL表示モジュール
modules/subdomain-urls.ts で Nuxt モジュールを作り、dev サーバー起動時にサブドメイン一覧を表示する。modules/ ディレクトリに置いたファイルは Nuxt が自動読み込みするため、nuxt.config.ts への登録は不要。
マークダウンを LP に統合(方式2)
記事統合の方針検討
マークダウン記事をLPに取り込む方式として2つの選択肢があった。
| 方式 | 概要 | メリット | デメリット |
|---|---|---|---|
| 方式1 | 記事をスタンドアロンのページとして表示 | シンプル | LP内に記事が表示されない |
| 方式2 | @nuxt/content で LP 内にマークダウンを表示 | LP の一部として記事が読める | 実装が少し複雑 |
方式2を採用。理由は、LP のコンテキスト(業種固有のデザイン・ナビゲーション)を保ったまま記事を読ませたいため。
content.config.ts の設定
// content.config.ts
import { defineCollection, defineContentConfig } from '@nuxt/content'
import { z } from 'zod'
export default defineContentConfig({
collections: {
articles: defineCollection({
type: 'page',
source: '**/*.md',
schema: z.object({
title: z.string(),
description: z.string().optional(),
industry: z.string(),
publishedAt: z.coerce.date(),
tags: z.array(z.string()).optional(),
}),
}),
},
})
useIndustryContent composable
サブドメインに応じたコンテンツを @nuxt/content の queryCollection で取得する composable を実装した。
// app/composables/useIndustryContent.ts
export const useIndustryContent = () => {
const subdomain = useSubdomain()
const { data: industryContent } = useAsyncData(
`industry-${subdomain.value}`,
() => queryCollection('articles')
.where('industry', subdomain.value)
.first()
)
return { industryContent, subdomain }
}
マークダウンの frontmatter に業種別データを格納
各業種のマークダウンファイルの frontmatter にキャッチコピー、サービス内容、FAQ などを入れ、LP コンポーネントがそのデータを読み取ってレンダリングする。
---
title: "美容室・サロン経営の税務サポート"
industry: "beauty"
publishedAt: "2026-02-07"
hero:
catchcopy: "サロン経営に集中できる環境をつくります"
subcopy: "美容室専門の税理士が、開業から確定申告まで"
services:
- title: "確定申告・記帳代行"
description: "日々のレシート整理から確定申告書の作成まで"
- title: "創業融資サポート"
description: "日本政策金融公庫の創業融資申請を全面サポート"
faq:
- q: "美容師の経費になるものは?"
a: "ハサミ、薬剤、タオル、セミナー参加費などが経費になります"
---
## 美容室経営者のための確定申告ガイド
美容室を経営していると、日々の施術に追われて...
業種別デザイン対応
アーキテクチャ
LP のコンポーネントを2層に分離した。
app/components/lp/
├── shared/ # 全業種共通
│ ├── Header.vue # ナビゲーションヘッダー
│ ├── Footer.vue # フッター
│ ├── Article.vue # 記事レンダラー
│ ├── Cta.vue # CTA ボタン
│ └── ...
├── beauty/ # 美容室専用
│ └── BeautyLanding.vue
├── fudosan/ # 不動産専用
│ ├── FudosanLanding.vue
│ ├── FudosanHero.vue
│ ├── FudosanStats.vue
│ ├── FudosanCaseStudy.vue
│ ├── FudosanServices.vue
│ └── FudosanLatestArticles.vue
├── freelance/ # フリーランス専用
│ └── FreelanceLanding.vue
└── ...
ページディスパッチャー
pages/index.vue がサブドメインを判定し、該当する Landing コンポーネントを動的に読み込む。
<script setup lang="ts">
const subdomain = useSubdomain()
const landingComponents: Record<string, Component> = {
beauty: defineAsyncComponent(() => import('~/components/lp/beauty/BeautyLanding.vue')),
fudosan: defineAsyncComponent(() => import('~/components/lp/fudosan/FudosanLanding.vue')),
freelance: defineAsyncComponent(() => import('~/components/lp/freelance/FreelanceLanding.vue')),
creator: defineAsyncComponent(() => import('~/components/lp/creator/CreatorLanding.vue')),
startup: defineAsyncComponent(() => import('~/components/lp/startup/StartupLanding.vue')),
}
const CurrentLanding = computed(() => landingComponents[subdomain.value] || landingComponents.beauty)
</script>
<template>
<component :is="CurrentLanding" />
</template>
Nuxt のコンポーネント自動インポートと prefix 設定
共通コンポーネントに Lp プレフィックスを付けたかったが、ファイル名が LpArticle.vue の場合、prefix Lp を付けると LpLpArticle になってしまう。これを回避するため、lp/shared/ 配下のファイル名から Lp プレフィックスを外し、Article.vue + prefix Lp = <LpArticle /> の形にした。
// nuxt.config.ts
export default defineNuxtConfig({
components: [
{ path: '~/components/lp/shared', prefix: 'Lp' },
{ path: '~/components/lp', prefix: '' },
],
})
不動産LP専用コンポーネント群
不動産LPは他業種とデザインを大きく変えるリクエストがあり、専用コンポーネントを5つ作成した。
FudosanHero.vue
画像を配置できるエリアを持つ迫力のあるヒーローセクション。濃いブルー系の背景に実績数値を散りばめたデザイン。
FudosanStats.vue
「相談実績 500件以上」「顧問先 120社」「対応エリア 首都圏全域」などの数値実績を大きく表示するセクション。不動産投資家は数字を重視するため、信頼感を与える目的で設計した。
FudosanCaseStudy.vue
実際の相談事例をカード形式で表示。「年間 200 万円の節税に成功」「法人化で税負担 35% 削減」など、具体的な成果を見せる。各カードに「詳しく見る」リンクを配置。
FudosanServices.vue
サービスメニューを画像カード形式で一覧表示。従来のテキストリストから、視覚的にわかりやすいカードレイアウトに変更した。
FudosanLatestArticles.vue
LP 内に最新記事3件を表示するセクション。queryCollection で不動産カテゴリの記事を取得し、日付順に並べる。
記事システムの実装
記事一覧ページ
pages/articles/index.vue で、現在のサブドメインに紐づく記事を日付順に一覧表示する。
個別記事ページ
pages/articles/[slug].vue で、マークダウン記事を ContentRenderer で表示する。@tailwindcss/typography の prose クラスで本文をスタイリングしている。
ヘッダーナビゲーション
共通ヘッダー lp/shared/Header.vue にナビゲーションリンクを追加。「サービス」「料金」「コンテンツ」などのリンクから、LP 内のセクションへのスムーススクロールと記事一覧ページへの遷移の両方に対応した。
スムーススクロールの実装
NuxtLink の問題
LP 内のセクションリンク(#services など)を <NuxtLink> で実装すると、NuxtLink の内部ハンドラが @click イベントより先に発火し、e.preventDefault() が間に合わない。結果、スムーススクロールではなくいきなりジャンプしてしまう。
解決策: <a> タグ + @click.prevent
<NuxtLink> をやめて <a> タグを使い、@click.prevent で確実にブラウザデフォルトを止めてからスクロール処理を行う。ページ遷移が必要な場合は navigateTo() を使う。
<template>
<nav>
<a
v-for="item in navItems"
:key="item.href"
:href="item.href"
@click.prevent="handleNavClick(item)"
>
{{ item.label }}
</a>
</nav>
</template>
<script setup lang="ts">
const handleNavClick = (item: NavItem) => {
if (item.href.startsWith('#')) {
// LP内セクションへのスムーススクロール
const el = document.querySelector(item.href)
if (el) {
const headerOffset = 64
const top = el.getBoundingClientRect().top + window.scrollY - headerOffset
window.scrollTo({ top, behavior: 'smooth' })
}
} else {
// 別ページへの遷移
navigateTo(item.href)
}
}
</script>
CSSはscopedで書き、ライブラリは使わない。scrollIntoView ではなく scrollTo を使うのは、ヘッダー分のオフセットを計算するため。
ページトランジション(左右スライド)
方向検知の仕組み
ページ遷移時に「進む」と「戻る」で左右のスライド方向を変える。Nuxt の pageTransition を動的に制御するため、app.vue でグローバルミドルウェアとして方向検知を実装した。
// app/app.vue
const transitionName = ref('page-forward')
// 進む: 左へスライドアウト → 右からスライドイン
// 戻る: 右へスライドアウト → 左からスライドイン
const router = useRouter()
router.beforeEach((to, from) => {
// popstate(ブラウザの戻る/進む)を検知
if (window.history.state?.back) {
transitionName.value = 'page-back'
} else {
transitionName.value = 'page-forward'
}
})
CSSトランジション
/* Forward: 左へ出て右から入る */
.page-forward-enter-active,
.page-forward-leave-active {
transition: transform 0.25s ease, opacity 0.25s ease;
}
.page-forward-enter-from {
transform: translateX(30px);
opacity: 0;
}
.page-forward-leave-to {
transform: translateX(-30px);
opacity: 0;
}
/* Back: 右へ出て左から入る */
.page-back-enter-active,
.page-back-leave-active {
transition: transform 0.25s ease, opacity 0.25s ease;
}
.page-back-enter-from {
transform: translateX(-30px);
opacity: 0;
}
.page-back-leave-to {
transform: translateX(30px);
opacity: 0;
}
nuxt.config.ts の固定の pageTransition 設定は削除し、app.vue で動的にトランジション名を切り替えることで、forward/back の方向に応じたアニメーションを実現した。
フッターの戦略的設計
「対応業種」セクションの削除
フッターから「対応業種」セクションを削除した。これは重要な戦略的判断で、以下の理由による。
- 各サブドメインは「その業種に特化した税理士事務所」に見せたい
- 「対応業種: 不動産、美容室、ITフリーランス...」と一覧を出すと、「なんでもやります」感が出てしまう
- 専門性の訴求力が下がる
beauty.example.com を訪れた美容室オーナーには、「美容室の税務だけをやっている事務所」に見えるのが理想。他のサブドメインの存在は知らせない。
EYANCAテンプレートの調査
テンプレートの割り当て計画
EYANCA(エヤンカ)は無料のWebテンプレート集で、27種類のテンプレートが登録不要でダウンロードできる。各業種のLPデザインにEYANCAのテンプレートを適用する計画を立てた。
デモページのHTML/CSSはWebFetchで中身を読めるため、デザインパターン(色、フォント、アニメーション、レイアウト手法)を抽出してNuxt 4のコンポーネントに移植する方針。ZIPファイルのダウンロードは不要で、デモページのコードから必要な部分を取り込む。
各業種への具体的なテンプレート割り当ては .claude/plans/next-steps.md に計画として保存し、次回セッションで着手する予定。
振り返り
うまくいったこと
- localhost サブドメイン方式: DNS設定なしでサブドメインの動作確認ができる。実際のサブドメイン構成とも合致するので、開発体験がよい
- コンポーネントの2層分離: shared と業種固有を分けたことで、不動産LPだけ大幅にデザインを変えるリクエストにもスムーズに対応できた
- @nuxt/content v3のマークダウン統合: frontmatter に業種固有のデータを持たせて LP コンポーネントが読み取る設計は、記事の展開がしやすい
つまずいたポイント
- npm の native binding エラー: Nuxt 4 は pnpm を使うべき。最初から pnpm にしておけば時間を節約できた
- nuxi init のインタラクティブプロンプト: フラグをつけてもプロンプトが出る。テンプレートの直接ダウンロードで回避
- NuxtLink のスムーススクロール問題: NuxtLink の内部ハンドラが先に発火する。
<a>+@click.prevent+navigateToが正解 - Tailwind CSS の非シリアライズ警告:
nuxt.config.tsにrequire()を直接書くと警告が出る。tailwind.config.tsに分離して解決
次にやること
- EYANCAテンプレートを各業種に適用
- 美容室LPの本格的なデザイン構築
- Cloudflareへのデプロイ(サブドメイン設定含む)
- 記事コンテンツの拡充(各業種3本ずつ)