6
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?

nuxt studioがOSSになったので触ってみよう!

6
Last updated at Posted at 2025-12-23

※この記事は人間が書いて、AIが推敲して、人間が最終チェックをしています。

はじめに

Nuxt Studio(OSS版)のベータがついに公開されました!

この記事では、Nuxt 4とNuxt Studioを使って、実際に動くブログアプリケーションを構築していきます。

私の作ったデモのリポジトリ

スクリーンショット 2025-12-23 13.16.02.png

プロジェクトのセットアップ

1. プロジェクトの作成とモジュールのインストール

まず、Nuxtプロジェクトを作成し、必要なモジュールをインストールします。

npm create nuxt@latest nuxt-studio-demo
cd nuxt-studio-demo
npm install @nuxt/content nuxt-studio

2. Nuxtの設定

nuxt.config.ts

nuxt.config.tsにNuxt StudioとNuxt Contentの設定を追加します。

// nuxt.config.ts
export default defineNuxtConfig({
  compatibilityDate: "2025-07-15",
  devtools: { enabled: true },
  modules: ["@nuxt/content", "nuxt-studio"],
  css: ["@/assets/css/main.css"],
  studio: {
    repository: {
      provider: "github",
      owner: "your-github-username",  // ← ここを自分のGitHubユーザー名に変更
      repo: "nuxt-studio-demo",       // ← ここを自分のリポジトリ名に変更
      branch: "main",
    },
    // dev: false,
  },
});

studio.repositoryの設定は、GitHubリポジトリと連携するための設定です。自分のGitHubユーザー名とリポジトリ名に置き換えてください。

content.config.ts

nuxt contentのためにスキーマやソースなどを設定しましょう。
今回はシンプルにブログだけ扱いたいのでこんな設定にしてます。

content.config.ts
export default defineContentConfig({
  collections: {
    blog: defineCollection({
      type: "page",
      source: "blog/*.md",
      schema: z.object({
        draft: z.boolean().default(true),
        title: z.string(),
        date: z.string(),
        author: z.string().optional(),
        description: z.string().optional(),
        body: z
          .object({
            html: z.string(),
          })
          .optional(),
      }),
    }),
  },
});

プロジェクト構造

以下のようなディレクトリ構造で進めていきます。

nuxt-studio-demo/
├── app/
│   ├── pages/
│   │   ├── index.vue              # ブログ一覧ページ
│   │   └── blog/
│   │       └── [slug].vue         # 個別ブログ記事ページ
│   ├── composables/
│   │   └── useBlogPosts.ts        # ブログ投稿取得のロジック
│   └── layouts/
│       └── default.vue
├── content/
│   └── blog/                      # ブログ記事のMarkdownファイル
│       ├── first-post.md
│       ├── second-post.md
│       └── ...
├── public/                        # 静的ファイル(画像など)
├── content.config.ts
└── nuxt.config.ts

ブログ機能の実装

1. Markdownコンテンツの作成

content/blog/ディレクトリにブログ投稿をMarkdown形式で作成します。

firstPost.md
---
title: Welcome to My Blog
description: This is the first blog post. Learn more about what this blog is about.
author: Your Name
date: 2025-12-20
draft: false
---

# Welcome to My Blog

This is the first post on my blog. I'm excited to share my thoughts and experiences with you.

## What to Expect

- Regular updates and insights
- Detailed tutorials and guides
- Personal reflections and experiences

## Getting Started

This blog uses **Nuxt Studio** for content management, which makes it easy to create and publish new posts.

ファイルの先頭の---で囲まれた部分はfrontmatterと呼ばれ、記事のメタデータを定義します。

2. Composableの作成

ブログ投稿を取得するロジックをuseBlogPosts.tsとして実装します。
※本当はpiniaでstore作ってポストの取得状態を管理すべきですが、割愛してuseStateにしてます...

useBlogPosts.ts
// app/composables/useBlogPosts.ts
interface BlogPost {
  path: string;
  title: string;
  date: string;
  author?: string;
  description?: string;
  body?: {
    html: string;
  };
}

export const useBlogPosts = () => {
  const posts = useState<BlogPost[]>("blog-posts", () => []);

  const fetchPosts = async () => {
    try {
      const data = await queryCollection("blog")
        .order("date", "DESC")
        .where("draft", "=", false)
        .all();
      posts.value = data as BlogPost[];
    } catch (error) {
      console.error("Failed to fetch blog posts:", error);
      posts.value = [];
    }
  };

  const getPostBySlug = (slug: string) => {
    if (!posts.value) return null;
    return posts.value.find((post: BlogPost) => {
      const postSlug = post.path.split("/").pop() || "";
      return postSlug === slug;
    });
  };

  return {
    posts: computed(() => posts.value || []),
    fetchPosts,
    getPostBySlug,
  };
};

ポイント:

  • queryCollection("blog")content/blog/ディレクトリのMarkdownファイルを取得
  • draft: falseのみを取得し、下書きを除外
  • 日付の降順でソート

3. ブログ一覧ページの作成

app/pages/index.vueにブログ投稿の一覧を表示するページを作成します。

app/pages/index.vue
<template>
  <div class="blog-list">
    <h1>Blog Posts</h1>

    <div v-if="posts.length > 0" class="posts-container">
      <article v-for="post in posts" :key="post.path" class="post-card">
        <NuxtLink :to="getPostUrl(post)" class="post-link">
          <h2 class="post-title">{{ post.title }}</h2>
        </NuxtLink>
        <div class="post-meta">
          <span class="date">{{ formatDate(post.date) }}</span>
          <span v-if="post.author" class="author">by {{ post.author }}</span>
        </div>
        <p class="post-excerpt">
          {{ post.description || "No description provided" }}
        </p>
        <NuxtLink :to="getPostUrl(post)" class="read-more">
          Read More →
        </NuxtLink>
      </article>
    </div>

    <div v-else class="no-posts">
      <p>No blog posts available yet.</p>
    </div>
  </div>
</template>

<script setup lang="ts">
const { posts, fetchPosts } = useBlogPosts();

await fetchPosts();

const getPostUrl = (post: any) => {
  if (!post.path) return "/";
  return post.path;
};

const formatDate = (dateStr: string) => {
  const date = new Date(dateStr);
  if (isNaN(date.getTime())) {
    return "Invalid date";
  }
  return date.toLocaleDateString("en-US", {
    year: "numeric",
    month: "long",
    day: "numeric",
  });
};
</script>

<style scoped>
.blog-list {
  padding: 2rem 0;
}

.post-card {
  padding: 1.5rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  margin-bottom: 1.5rem;
  transition: box-shadow 0.3s, transform 0.3s;
}

.post-card:hover {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  transform: translateY(-2px);
}

.post-title {
  font-size: 1.5rem;
  color: #333;
}

.post-meta {
  display: flex;
  gap: 1rem;
  margin: 0.5rem 0 1rem 0;
  font-size: 0.9rem;
  color: #666;
}

.read-more {
  color: #0066cc;
  text-decoration: none;
}
</style>

スクリーンショット 2025-12-23 13.42.46.png

4. 個別記事ページの作成

app/pages/blog/[slug].vueに動的ルーティングで個別記事を表示するページを作成します。

app/pages/blog/[slug].vue
<template>
  <div v-if="post" class="blog-post">
    <article class="post-content">
      <h1 class="post-title">{{ post.title }}</h1>

      <div class="post-meta">
        <span class="date">{{ formatDate(post.date) }}</span>
        <span v-if="post.author" class="author">by {{ post.author }}</span>
      </div>

      <div class="post-body">
        <ContentRenderer :value="post" />
      </div>
    </article>

    <nav class="post-nav">
      <NuxtLink to="/" class="back-link">← Back to Posts</NuxtLink>
    </nav>
  </div>

  <div v-else class="not-found">
    <h1>Post Not Found</h1>
    <p>The blog post you're looking for doesn't exist.</p>
    <NuxtLink to="/">Back to Posts</NuxtLink>
  </div>
</template>

<script setup lang="ts">
const route = useRoute();
const { posts, fetchPosts, getPostBySlug } = useBlogPosts();

const slug = Array.isArray(route.params.slug)
  ? route.params.slug[0]
  : route.params.slug;

const post = computed(() => getPostBySlug(slug));

const formatDate = (dateStr: string) => {
  const date = new Date(dateStr);
  if (isNaN(date.getTime())) {
    return "Invalid date";
  }
  return date.toLocaleDateString("en-US", {
    year: "numeric",
    month: "long",
    day: "numeric",
  });
};

useHead({
  title: computed(() => post.value?.title || "Blog Post"),
});
</script>

<style scoped>
.blog-post {
  padding: 2rem 0;
}

.post-content {
  max-width: 800px;
  margin: 0 auto;
}

.post-title {
  font-size: 2.5rem;
  margin-bottom: 1rem;
  color: #333;
}

.post-body {
  font-size: 1.1rem;
  line-height: 1.8;
  color: #333;
}

.post-body :deep(h2) {
  font-size: 1.8rem;
  margin: 2rem 0 1rem 0;
}

.post-body :deep(code) {
  background-color: #f5f5f5;
  padding: 0.2rem 0.4rem;
  border-radius: 3px;
}

.post-body :deep(pre) {
  background-color: #f5f5f5;
  padding: 1rem;
  border-radius: 4px;
  overflow-x: auto;
}
</style>

ポイント:

  • [slug].vueで動的ルーティングを実装
  • ContentRendererコンポーネントでMarkdownをHTMLに変換して表示
  • :deep()セレクタでMarkdownから生成されたHTML要素にスタイルを適用

スクリーンショット 2025-12-23 13.43.18.png

Nuxt Studioの使い方

GitHub OAuthアプリの作成

Nuxt Studioをセルフホストで利用する場合、本番モードでコンテンツを編集・公開するにはGitHub OAuthアプリを作成する必要があります。

OAuthアプリの作成手順

  1. GitHubのDeveloper settingsにアクセス
  2. 「New GitHub App」をクリック
  3. 必要事項を入力
    • 今回はdev:falseを利用し、ローカルで本番モードを試すため、Homepage URL、Callback URLはhttp://localhost:3000にすること。
    • OAuthなのでRequest user authorization (OAuth) during installationのチェックをつけること
  4. 「Create GitHub App」をクリック
  5. 作成されたアプリのページでClient IDを確認
  6. 「Generate a new client secret」をクリックしてClient Secretを生成し、控えておく

重要: Client Secretは一度しか表示されないため、必ず控えてください。

3. 環境変数の設定

プロジェクトルートに.envファイルを作成し、GitHub OAuthの認証情報を設定します。

# .env
STUDIO_GITHUB_CLIENT_ID=<your_client_id>
STUDIO_GITHUB_CLIENT_SECRET=<your_client_secret>

その後、.envファイルを開いて、実際のClient IDとClient Secretに置き換えてください。

4. 本番モードでの起動

開発サーバーを本番モードで起動します。

# 本番モードでテストするには、nuxt.config.tsで dev: false を設定
npm run dev

ブラウザでhttp://localhost:3000/_studioにアクセスし、githubの認証を通過すると、dev環境で表示されていたnuxt studioのUIを表示することができるようになります。

スクリーンショット 2025-12-23 13.51.05.png

5. コンテンツの編集

Nuxt Studioでは、以下のような機能が利用できます:

  • ビジュアルエディタ: Markdownをリアルタイムでプレビューしながら編集
  • ファイル管理: content/blog/内のファイルをGUI上で管理
  • メタデータ編集: frontmatterを視覚的に編集
  • リアルタイムプレビュー: 変更内容をその場で確認

スクリーンショット 2025-12-23 13.53.35.png

開発モードと本番モード

  • 開発モード (npm run dev): ファイル変更はローカルファイルシステムに直接反映されます。GitへのコミットはIDEやコマンドラインから行います。
  • 本番モード: 変更をGitHubに直接コミット&プッシュできます(Publishボタンを使用)。

7. 変更のコミット&プッシュ(本番モードのみ)

本番モード(dev: false)で実行している場合、Nuxt Studio上で編集した内容を直接GitHubにコミット&プッシュできます。

  1. 編集完了後、右上の「Publish」ボタンをクリック
  2. コミットメッセージを入力
  3. 「Commit & Push」でGitHubに反映

スクリーンショット 2025-12-23 13.54.27.png

スクリーンショット 2025-12-23 13.55.38.png

余談

この記事では紹介してませんが、現状だと誰でもoauth認証して記事変え放題なので、なんらかの認証は必須です。
私はとりあえずmiddlewareでbasic認証入れてます

まとめ

Nuxt StudioがOSSになったことで、無料かつ、手軽にNuxtをブログ化できるようになりました。
正式版のリリースを楽しみにしています。

よかった点

  • nuxt製のサイトに組み込む形で動作させられる(ヘッドレスcmsのようにcmsがサイトから分離されない)
  • カスタマイズの制約が少ない

課題点

  • 非開発者が触るにはまだハードルが高い
  • 当たり前だがcmsが本来デフォルトで用意しているような機能も作らないといけないので、導入コストは少し高い

その他

今後の追加機能も気になりますね
スクリーンショット 2025-12-23 13.57.23.png

参考リンク

6
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
6
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?