高速なコンテンツ重視の WEB サイトを構築したいという人向けに新たな Static Site Generator(静的サイトジェネレーター:SSG)が登場しました。その名前は Astro。Next.js や Remix などの React フレームワークと同様に注目度の高いフレームワークの一つです。ブログサイトやオープソースのサイト(例:create-t3-app)などで利用され活発に更新が行われているので 2024年8月20日最新のバージョンはのAstro4.14.2です。

リリース当初は Static Site Generator(静的サイトジェネレーター:SSG)として登場した Astroですが現在はSSR(Sever Side Rendring)も備え, Static Site Generatorではなくフルスタックフレームワークとして開発が行われています。
fukidashi

本文書では公開当初は Astro1.0 を利用していましたがバージョンが上がるたびに記事の更新を行っています。Astro2.0 で追加された Content Collections API や SSR(Sever Side Rendring)をページ毎に切り替える Hybrid Rendering についても動作確認を行っています。その他にも Prisma を経由したデータベースへの接続、API Routes を利用した CRUD(Create, Read, Update, Delete)についても動作確認を行っています。

Astro2.0では新たにContent Collectionsという機能が提供されMarkdownのfront MatterのSchemaを定義してバリデーションを行うことができTypeScriptのType-safeを実現しています。markdown ファイルからデータを取得するコードを記述する際に AutoComplete によって frontmatter がどのようなプロパティで構成されているか知ることができます。
fukidashi

Astro2.1に登場した画像の最適化を行うAssets機能については別記事で公開しています。

Astro3.0に登場した View Transitions については別記事で公開しています。

目次

Astroとは

Astroはアプリケーション開発時に利用したJavaScriptコードをビルド時に可能な限り除いた静的なHTMLファイルを生成することでブログのようなコンテンツを重視したWEBサイトを高速化することを目的としたall-in-one web frameworkです(JavaScriptを全く含まないHTMLのみのWEBサイトを構築することができます)。ページ移動時にはSingle Page Application(SPA)で利用されるJavaScriptを利用したページの移動ではなくページ移動にはページのリロード(a タグを利用してリンクを設定)が行われます。そのため Astro は Single Page Application(SPA)ではなく Multi Page Application(MPA)として動作します。現在のバージョンではView Transitionsを利用することでMPAでありながらSPAのようなスムーズなページ移動を実現しています。

Astro ではビルド後に作成される JavaScript のバンドルサイズを小さくして送信量を抑えること、クライアント側での JavaScript 処理の負荷をなくすことで高速化を実現しています。開発時に利用する JavaScript はビルドの際に取り除かれますが、カルーセルやショッピングカードなどクライアント側(ブラウザ上)で JavaScript が必要な場合はコンポーネント単位で JavaScript を利用するかどうか設定することができます。コンポーネントは完全に独立させることができるためブラウザ上ではあるコンポーネントは静的、あるコンポーネントは Javascript のロードが完了しインタラクティブな状態、あるコンポーネントはJavaScriptのロード中といった状態でページを表示することができます(Astro Island)。

Astro Islandの図
Astro Islandの図

JavaScriptのサイズを少なくすることによる高速化と同様に個人的に面白いと思ったのがAstro Islandによりコンポーネントが独立しているためコンポーネント作成時に開発者が好きなUIのフレームワークを選択できる点です。本ブログでこれまでに紹介したStatic Site Generator機能を持つGatsbyやNext.jsであればReact, Nuxt.jsであればVue.js, SveltKitであればSvelteを利用することが必須になりますがAstroではコンポーネント単位でReact, Vue.js, Svelteなど好きなUIのフレームワーク/ライブラリを利用することができます。それらのフレームワーク/ライブラリを利用しないことも可能でAstroの独自の記述方法を利用してコードを記述することができます。

利用できるUIフレームワーク/ライブラリ
利用できるUIフレームワーク/ライブラリ

コンポーネントを作成する際にあるコンポーネントではReact、別のコンポーネントではVue.jsを利用するといったことが可能です。好きなフレームワーク/ライブラリで作成したコンポーネントはクライアント側で利用するJavaScriptがなければビルド時にすべて静的なHTMLファイルへと変換されます。

JavaScriptで記述したコードはビルド後にどのような内容として生成されるのか、好きなフレームワークを選択できるとはどういうものなのかAstroを実際に動作させて確認していきます。

プロジェクトの作成

npm, yarn, pnpmコマンドを利用してAstroプロジェクトを作成することができます。ここではnpmコマンドを利用してプロジェクトの作成を行います。インストール方法についてはドキュメントを参考に行なっています。


 % npm create astro@latest

> npx
> create-astro

 astro   Launch sequence initiated.

   dir   Where should we create your new project?
         ./my-astro-site

  tmpl   How would you like to start your new project?
         Include sample files
      ✔  Template copied

  deps   Install dependencies?
         Yes
      ✔  Dependencies installed

    ts   Do you plan to write TypeScript?
         Yes

   use   How strict should TypeScript be?
         Strict
      ✔  TypeScript customized

   git   Initialize a new git repository?
         Yes
      ✔  Git initialized

  next   Liftoff confirmed. Explore your project!

         Enter your project directory using cd ./stale-spectrum 
         Run npm run dev to start the dev server. CTRL+C to stop.
         Add frameworks like react or tailwind using astro add.

         Stuck? Join us at https://astro.build/chat

╭─────╮  Houston:
│ ◠ ◡ ◠  Good luck out there, astronaut! 🚀

コマンドを実行するとプロジェクト名の設定とテンプレートの選択を行うことができます。プロジェクト名のデフォルトはAstroという名前のため宇宙に関連した名称がランダムに表示されるようなのでそのままデフォルトを利用するか任意の名前をつけてください。ここでは古いバージョンのデフォルト名であったmy-astro-siteを設定します。

どのようなプロジェクトを作成したいか確認されます。Astroを初めて利用する場合は”Include sample files (recommended)”を選択します。また本文書は”Include sample files (recommended)”で作成されるファイルを元に説明を行っています。その他にblog templateとEmptyを選択することができます。ブログを作成したい場合はblog templateが参考になりますのでblog templateを選択してください。

TypeScriptについての選択もありますがTypeScriptを選択しrecommendedのStrictを選択します。AstroではTypeScriptの環境がデフォルトで設定されているので追加で何か設定を行うことはありません。

フォルダ構成

作成されたmy-astro-siteに移動してフォルダ構成を確認します。プロジェクトフォルダ直下にはnode_modules, public, srcフォルダがあります。srcフォルダの中にはさらにcomponents, layouts, pagesフォルダを確認することができその中に拡張子astroを持つファイルが存在します。

フォルダ構成
フォルダ構成
astroの拡張子を持つファイルはAstro Componentsと呼ばれAstro独自の記述方法(シンタックス)で作成することができます。ReactやVue.jsなどのフレームワークを利用しなくてもAstroコンポーネントのみでアプリケーションを作成することができます。
fukidashi

srcフォルダの中にソースコードを記述し、publicフォルダにはフォントや画像など静的なファイルを保存することができます。

astro.config.mjsがastroに関する設定ファイルです。本文書ではReactなどを追加設定する場合に利用します。package.jsonファイルにはインストールされているパッケージやスクリプトが記述されています。


{
  "name": "my-astro-site",
  "type": "module",
  "version": "0.0.1",
  "scripts": {
    "dev": "astro dev",
    "start": "astro dev",
    "build": "astro check && astro build",
    "preview": "astro preview",
    "astro": "astro"
  },
  "dependencies": {
    "astro": "^4.14.2",
    "@astrojs/check": "^0.9.3",
    "typescript": "^5.5.4"
  }
}

Astroのインストール中に選択するtemplateの”an empty project”を選択した場合にはpagesフォルダにindex.astroファイルが入っているだけです。

"an empty project"を選択した場合
“an empty project”を選択した場合

“an empty project”テンプレートの場合は、開発サーバを起動(npm run dev)してもAstroの文字のみブラウザ上に表示されます。

開発サーバの起動でページの確認
開発サーバの起動でページの確認

VScodeのExtentionsのインストール

components, layouts, pagesの中に作成されている拡張子astroのコードはハイライトされていないのでVSCodeを利用している場合はExtentionsのAstroをインストールします。

AstroのExtensions
AstroのExtensions

インストールを行うとコードがハイライトされます。

アプリケーションの起動

srcフォルダの中のファイルの詳細を確認する前に”npm run dev”コマンドを実行して開発サーバを起動します。


 % npm run dev

> my-astro@0.0.1 dev
> astro dev

15:27:53 [types] Generated 2ms

 astro  v4.14.2 ready in 228 ms

┃ Local    http://localhost:4321/
┃ Network  use --host to expose

localhost:4321にアクセスを行うとブラウザには以下の画面が表示されます。

Astroの初期画面
Astroの初期画面

ブラウザに表示されている内容はsrc¥pages¥index.astroファイルに記述されています。一つのファイルにJavaScriptのimport, HTML, styleタグが記述されていることがわかります。Vue.js, Svelteの記述方法に似ていますがどちらでもありません。これがAstro独自の記述方法です。


---
import Layout from '../layouts/Layout.astro';
import Card from '../components/Card.astro';
---

<Layout title="Welcome to Astro.">
	<main>
		<svg
//略
		</svg>
		<h1>Welcome to <span class="text-gradient">Astro</span></h1>
		<p class="instructions">
			To get started, open the directory <code>src/pages</code> in your project.<br />
			<strong>Code Challenge:</strong> Tweak the "Welcome to Astro" message above.
		</p>
		<ul role="list" class="link-card-grid">
			<Card
				href="https://docs.astro.build/"
				title="Documentation"
				body="Learn how Astro works and explore the official API docs."
			/>
			<Card
				href="https://astro.build/integrations/"
				title="Integrations"
				body="Supercharge your project with new frameworks and libraries."
			/>
			<Card
				href="https://astro.build/themes/"
				title="Themes"
				body="Explore a galaxy of community-built starter themes."
			/>
			<Card
				href="https://astro.build/chat/"
				title="Community"
				body="Come say hi to our amazing Discord community. ❤️"
			/>
		</ul>
	</main>
</Layout>

<style>
	main {
//略
</style>

Astro 設定ファイル

Astroの設定ファイルであるastro.config.mjsファイルではどのようなことが設定できるのか確認します。

デフォルトではportは4321ですがportを変更したい場合はastro.config.mjsファイルで行うことができます。下記のようにdefineConfigの引数にserverプロパティを持つオブジェクトを設定することができます。ポート番号を8080に設定しています。設定後は一度npm run devコマンドを停止し再度実行すると変更したportで起動します。


import { defineConfig } from 'astro/config';

// https://astro.build/config
export default defineConfig({
  server: { port: 8080 },
});
astro.config.mjsファイルを更新した場合npm run devコマンドを実行したターミナルには”Configuration file updated. Restarting…”が表示されるので設定ファイルの更新が即座に反映されます。
fukidashi

Astro Components

Astro独自の記述方法(Astro Syntax)で記述されているコンポーネントはAstro Componentsと呼ばれます。Astro Componentsは大きく2つのパートに分かれており一つはComponent Script(—と—で囲まれている部分:frontmatter)、もう一つはComponent Templateです。

Component Script(frontmatter)ではコンポーネントのimportやコンポーネントに渡されるprops、変数の定義、外部リソースからのデータ取得を行うJavaScriptコードを記述することができます。

Component TemplateにはHTMLを記述することができます。HTMLだけではなくJavaScriptの埋め込み、importしたコンポーネントやAstro独自のディレクティブも利用できます。styleタグを追加してCSSも設定できます。

Cardコンポーネント

componentsフォルダにあるCard.astroを確認してAstro Componentsの記述方法を確認します。propsを利用して親コンポーネントから子コンポーネントに値を渡す方法が確認できます。


---
export interface Props {
	title: string;
	body: string;
	href: string;
}

const { href, title, body } = Astro.props;
---

<li class="link-card">
	<a href={href}>
		<h2>
			{title}
			<span>→</span>
		</h2>
		<p>
			{body}
		</p>
	</a>
</li>
<style>
//略
</style>

Component Script(frontmatter)の中ではTypeScriptのinterfaceでPropsの型を定義し、親コンポーネントから渡されるpropsはAstro.propsの中に保存されてhref, title, bodyとして取り出して利用できることがわかります。渡されたpropsは{}を利用することでComponent Templateの中で利用できます。

propsを渡している親コンポーネントのindex.astroファイルを確認するとCardコンポーネントへのpropsの渡し方も確認できます。Cardタグを4つ利用していますがhref, title, bodyの値をそれぞれのCardタグで異なる値を設定しています。


<ul role="list" class="link-card-grid">
	<Card
		href="https://docs.astro.build/"
		title="Documentation"
		body="Learn how Astro works and explore the official API docs."
	/>
	<Card
		href="https://astro.build/integrations/"
		title="Integrations"
		body="Supercharge your project with new frameworks and libraries."
	/>
	<Card
		href="https://astro.build/themes/"
		title="Themes"
		body="Explore a galaxy of community-built starter themes."
	/>
	<Card
		href="https://astro.build/chat/"
		title="Community"
		body="Come say hi to our amazing Discord community. ❤️"
	/>
</ul>

Layoutコンポーネント

pages/index.astroファイル確認するとimportしたLayoutタグでラップされていることがわかります。


---
import Layout from '../layouts/Layout.astro';
import Card from '../components/Card.astro';
---
<Layout title="Welcome to Astro.">
  <main>
    <h1>Welcome to <span class="text-gradient">Astro</span></h1>
    //略
  </main>
</Layout>
//略

Layoutコンポーネントに対してpropsでtitleを渡しています。

layoutsフォルダのLayout.astroを確認するとbodyタグの中にslotタグが確認できます。


---
export interface Props {
	title: string;
}

const { title } = Astro.props;
---

<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width" />
		<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
		<meta name="generator" content={Astro.generator} />
		<title>{title}</title>
	</head>
	<body>
		<slot />
	</body>
</html>
<style is:global>
//略
</style>

このslotの場所にindex.astroファイルのLayoutタグの中に記述したコードが挿入されて表示されます。Vue.jsやSvelteでのslotの利用方法と同じです。Layoutコンポーネントを利用することで共通のレイアウトを複数ページに適用することができます。

Layout.astroファイルのstyleタグのhtmlでbackgroundのプロパティが”#13151a”に設定されているのでこの行をコメントしておきます。コメントすると背景が白になります。

プロジェクト作成時のテンプレートに”Include sample files”を選択することでAstrto Componentの記述方法の基礎とレイアウトファイルの作成/適用方法を理解することができます。

テンプレートのEmpty Projectを選択した場合はsrc/pagesフォルダにindex.astroのみ作成されます。
fukidashi

pageコンポーネント

AstroはNext.js(Pages Router)やRemix, Nuxtなどと同様にファイルベースルーティングを採用しているのでpagesフォルダの中にファイルを作成するだけでルーティングが自動で設定されます。

pagesフォルダの中にabout.astroファイルを作成します。index.astroと同様にLayoutコンポーネントでラップしています。CSSを利用しない場合はstyleタグを省略することができます。


---
import Layout from '../layouts/Layout.astro';
---
<Layout title="Aboutページ">
  <h1>Astroについて</h1>
</Layout>

ファイル作成後にhttp://localhost:4321/aboutにブラウザからアクセスするとaboutページが表示されます。ファイルベースルーティングなので手動でルーティングを設定する必要はありません。Layoutコンポーネントを利用しているのでブラウザのタブにはpropsのtitleで設定した”Aboutページ”が表示されています。

Aboutページの表示
Aboutページの表示

画像の利用

コンポーネント内で画像を利用するためにsrcフォルダにimagesフォルダを作成して画像を保存します。ここではAstroの公式サイトから取得したロゴを利用します。ファイル名はastro-logo-dark.pngです。

ファイルを指定してimportした後にimgタグのsrc属性で指定することができますが、logo.srcと設定する必要があります。


---
import Layout from '../layouts/Layout.astro';
import logo from '../images/astro-logo-dark.png';
---
<Layout title="Aboutページ">
  <h1>Astroについて</h1>
  <img src={logo.src} />
</Layout>

ブラウザで確認すると画像が表示されていることがわかります。

画像の表示設定
画像の表示設定

Imageコンポーネントを利用することもできます。Imageコンポーネントを利用することが画像を最適化してくれます。alt propsの設定が必要です。


---
import Layout from '../layouts/Layout.astro';
import { Image } from 'astro:assets';
import logo from '../images/astro-logo-dark.png';
---
<Layout title="Aboutページ">
  <h1>Astroについて</h1>
  <Image src={logo} alt="logo"/>
  <!-- <img src={logo.src} />-->
</Layout>

srcフォルダではなくpublicフォルダに配置することも可能です。publicフォルダに保存した場合はパスを利用して設定することができます。ここではpublicフォルダの直下にastro-logo-dark.pngファイルを保存しています。


---
import Layout from '../layouts/Layout.astro';
---
<Layout title="Aboutページ">
  <h1>Astroについて</h1>
  <img src="/astro-logo-dark.png" />
</Layout>

ほかのサーバ(Astroのドキュメントサイト)からの画像も利用することができます。


---
import Layout from '../layouts/Layout.astro';
---
<Layout title="Aboutページ">
  <h1>Astroについて</h1>
  <img src="https://astro.build/assets/press/astro-logo-dark.svg" />
</Layout>

クライアントサイドのJavaScript

React, Vue.jsやSvelteを利用せずクライアントサイドのブラウザのみでJavaScriptを実行したい場合にはscriptタグを利用することで実行することができます。クライアントサイドでのみJavaScriptを実行するとはどういうことか動作確認のためにconsole.logを2箇所に設定します。一つはComponent Script(frontmatter)の中にconsole.log(‘server’)もう一つはscriptタグの中にconsole.log(‘client’)を記述します。


---
import Layout from '../layouts/Layout.astro';
console.log('server');
---

<Layout title="Aboutページ">
  <h1>Astroについて</h1>
</Layout>

<script>
  console.log('client');
</script>

http://localhost:4321/aboutにブラウザからアクセスすると”npm run dev”コマンドを実行したターミナルには”server”が表示され、ブラウザのデベロッパーツールのコンソールには”client”が表示されます。このことからscriptタグに設定したconsole.logのみブラウザで実行されていることがわかります。

ボタンをクリックしたらalertを実行するコードをscriptタグの中に記述して記述したJavaScriptコードがブラウザ上で実行できるか確認します。


---
import Layout from '../layouts/Layout.astro';
console.log('server');
---

<Layout title="Aboutページ">
  <h1>Astroについて</h1>
  <div>
    <button id="btn">click</button>
  </div>
</Layout>

<script>
  document.getElementById('btn')?.addEventListener('click', () => {
    alert('Click!!');
  });
</script>

もしscriptタグに記述したコードをComponent Script(frontmatter)の中で記述すると”document is not defind. “のメッセージがブラウザ上に表示されます。


---
import Layout from '../layouts/Layout.astro';
document.getElementById('btn')?.addEventListener('click', () => {
  alert('Click!!');
});
---
//略

表示されているメッセージではdocumentはサーバからアクセスできないのでfrontmatterではなくscriptタグにコードを記述するように説明されています。

エラーメッセージの表示
エラーメッセージの表示

Component Script(frontmatter)で定義した変数をscriptタグの中で利用することもできます。


---
import Layout from '../layouts/Layout.astro';
const message = 'Click!!';
---

<Layout title="Aboutページ">
  <h1>Astroについて</h1>
  <div>
    <button id="btn">click</button>
  </div>
</Layout>

<script define:vars={{ message }}>
  document.getElementById('btn')?.addEventListener('click', () => {
    alert(message);
  });
</script>

ボタンをクリックするとComponent Scriptで定義したmessageの中の”Click!!”が表示されます。

frontmatterで定義した変数の表示
frontmatterで定義した変数の表示

ビルドの実行

about.astroページを作成したのでビルドを行うとどのようなフォルダ、ファイルが作成されるのか確認します。ローカルでのビルドの実行は”npm run build”コマンドで実行することができます。


% npm run build

> my-astro@0.0.1 build
> astro check && astro build

16:09:08 [vite] Re-optimizing dependencies because vite config has changed
16:09:09 [types] Generated 80ms
16:09:09 [check] Getting diagnostics for Astro files in /Users/mac/Desktop/my-astro...
src/pages/about.astro:13:9 - warning astro(4000): This script will be treated as if it has the `is:inline` directive because it contains an attribute. Therefore, features that require processing (e.g. using TypeScript or npm packages in the script) are unavailable.

See docs for more details: https://docs.astro.build/en/guides/client-side-scripts/#script-processing.

Add the `is:inline` directive explicitly to silence this hint.

13 <script define:vars={{ message }}>
           ~~~~~~~~~~~

Result (6 files): 
- 0 errors
- 0 warnings
- 1 hint

16:09:12 [types] Generated 51ms
16:09:12 [build] output: "static"
16:09:12 [build] directory: /Users/mac/Desktop/my-astro/dist/
16:09:12 [build] Collecting build info...
16:09:12 [build] ✓ Completed in 59ms.
16:09:12 [build] Building static entrypoints...
16:09:13 [vite] ✓ built in 592ms
16:09:13 [build] ✓ Completed in 650ms.

 generating static routes 
16:09:13 ▶ src/pages/about.astro
16:09:13   └─ /about/index.html (+7ms)
16:09:13 ▶ src/pages/index.astro
16:09:13   └─ /index.html (+3ms)
16:09:13 ✓ Completed in 19ms.

16:09:13 [build] 2 page(s) built in 741ms
16:09:13 [build] Complete!

ビルドが完了するとプロジェクトフォルダ直下にdistフォルダが作成されます。distフォルダを展開するとaboutフォルダが作成されていることがわかります。aboutフォルダのindex.htmlファイルを見るとHTMLのみで記述されたファイルであることがわかります。下記の画像にはdistフォルダの中身を表示していますがJavaScriptに関連するファイルはなくindex.htmlファイルにscriptタグが追加されコードが記述されていることがわかります。

ビルド後のaboutページの確認
ビルド後のaboutページの確認

ビルドしたデータは”npm run preview”で確認することができます。


% npm run preview 

> my-astro@0.0.1 preview
> astro preview


 astro  v4.14.2 ready in 13 ms

┃ Local    http://localhost:4321/
┃ Network  use --host to expose

外部リソースからのデータ取得

外部リソースからデータを取得したい場合の方法について確認します。外部リソースには無料で利用できるJSONPlaceHolderを利用します。https://jsonplaceholder.typicode.com/postsにアクセスすると100件分のpostデータを取得することができます。ブラウザから直接アクセスすることも可能です。

pagesフォルダの下にpostsフォルダを作成しその中にindex.astroファイルを作成します。データ取得するためのfetch関数はComponent Script(frontmatter)の中に記述します。awaitを利用していますがasyncを明示的に設定する必要はありません。取得したデータはmap関数を利用して展開します。ブラウザ上には取得したPostのタイトルを表示させています。


---
import Layout from '../../layouts/Layout.astro';

export type Post = {
  useId: number;
  id: number;
  title: string;
  body: string;
};

const response = await fetch('https://jsonplaceholder.typicode.com/posts');
const posts: Post[] = await response.json();
---

<Layout title="Post一覧ページ">
  <h1>Post一覧</h1>
  <ul>
    {posts.map((post) => <li>{post.title}</li>)}
  </ul>
</Layout>

ブラウザから/postsにアクセスするとJSONPlaceHolderから取得したpostデータ一覧が取得できます。

リモートから取得したデータの表示
リモートから取得したデータの表示

Dynamicルーティングの設定

Post一覧を表示することができたのでPostのタイトルをクリックすると個別のPostページが表示できるように設定を行います。/posts/1, /posts/2のidによって表示させる内容を変更させるためにDynamicルーティングを設定します。

postsフォルダに下に[id].astroファイルを作成します。Dynamicルーティングを設定する際にブラケット[]の中に変数を設定します。URLの/postsの後に続くidはAstro.paramsから取得することができます。取得する際は[]の中で設定した変数名を利用します。


---
const { id } = Astro.params;
console.log(id)
---
<h1>Post:{id}</h1>

[id].astroファイルを作成後に/posts/1にアクセスすると下記のエラーメッセージが表示されます。Dynamicルーティングを利用したい場合はgetStaticPaths関数が必須でコンポーネントでexportしなければならないと表示されています。

getStaticPaths関数に関するエラー
getStaticPaths関数に関するエラー

/posts/1, /posts/2, /posts/3にアクセスした場合にページが表示されるようにgetStaticPaths関数の設定を手動で行います。


---
export async function getStaticPaths() {
  return [
    { params: { id: 1 } },
    { params: { id: 2 } },
    { params: { id: 3 } }
  ];
}

const { id } = Astro.params;
console.log(id)
---
<h1>Post:{id}</h1>

getStaticPathsを設定後に/posts/1にアクセスするとAstro.paramsから取得したidを表示することができます。

Post Idの表示
Post Idの表示

getStaticPathsで設定していない/posts/4にアクセスするとNot Foundページが表示されます。

404 Not Foundページの表示
404 Not Foundページの表示

手動で設定していたgetStaticPaths関数の設定をJSONPlaceHolderから取得したデータを利用して設定できるように変更を行います。


---
import type { Post } from './index.astro';
export async function getStaticPaths() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const posts: Post[] = await response.json();

  return posts.map((post) => {
    return {
      params: { id: post.id },
    };
  });
}

const { id } = Astro.params;
---

<h1>Post:{id}</h1>

postデータは1から100までのidを持っているのでブラウザから/posts/1,..,/posts/100までの範囲であればidがブラウザ上に表示されます。/posts/101では404 Not Foundエラーが表示されます。

JSONPlaceHolderから取得できるデータの中にはPostの中身も入っているのでgetStaticPaths関数を利用することでPropsとして渡すことができます。


---
import type { Post } from './index.astro';
export async function getStaticPaths() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const posts: Post[] = await response.json();

  return posts.map((post) => {
    return {
      params: { id: post.id },
      props: { post },
    };
  });
}

const { post } = Astro.props;
---

<h1>{post.title}</h1>
<div>
  {post.body}
</div>

ブラウザで確認するとPostのタイトルと内容が表示されます。

Postの内容を表示
Postの内容を表示

404ページ

ページが存在しないURLにアクセスすると404 Not Foundのエラーページが表示されました。エラーページをカスタマイズしたい場合にはpageフォルダに404.astroファイルを作成することで実現できます。


---
import Layout from '../layouts/Layout.astro';
---

<Layout title="Not Found Page">
  <h1>お探しのページは見つかりせん。</h1>
</Layout>

404ページのカスタマイズ
404ページのカスタマイズ

リンクの設定

PostページへのアクセスはブラウザのURLに手動で/posts/4を入力して行なっていましたがPost一覧からアクセスできるようにリンクの設定を行います。aタグを設定後Post一覧のリンクをクリックするとPostの個別ページが表示されます。


---
import Layout from '../../layouts/Layout.astro';

export type Post = {
  useId: number;
  id: number;
  title: string;
  body: string;
};

const response = await fetch('https://jsonplaceholder.typicode.com/posts');
const posts: Post[] = await response.json();
---

<Layout title="Post一覧ページ">
  <h1>Post一覧</h1>
  <ul>
    {
      posts.map((post) => (
        <li>
          <a href={`/posts/${post.id}`}>{post.title}</a>
        </li>
      ))
    }
  </ul>
</Layout>

paginationの設定

Astroはpaginationの機能を備えているので独自に機能を追加することなく利用することができます。paginationを利用するためにpostsフォルダのindex.astroの名前を[…page].astroに変更します。paginationを利用するためにはブラケット[]をつけたDynamic Routesである必要があります。ページを移動する際に/posts/1, /posts/2のようにページ番号をつけてアクセスするためです。

paginateの設定はgetStaticPathsで行います。Astro.propsのpageの中にページに関する情報が含まれています。


---
import Layout from '../../layouts/Layout.astro';
import type { Page, GetStaticPathsOptions } from 'astro';

export type Post = {
  useId: number;
  id: number;
  title: string;
  body: string;
};

export interface Props {
  page: Page<Post>;
}

export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const posts = await response.json();
  return paginate(posts, { pageSize: 5 });
}

const { page } = Astro.props;
---

<Layout title="Post一覧ページ">
  <h1>Post一覧</h1>
  <ul>
    {
      page.data.map((post) => (
        <li>
          <a href={`/posts/${post.id}`}>{post.title}</a>
        </li>
      ))
    }
  </ul>
  {page.url.prev ? <a href={page.url.prev}>Previous</a> : null}
  {page.url.next ? <a href={page.url.next}>Next</a> : null}
</Layout>

/postsにアクセスするとpageSizeで指定した5件とNextのリンクが表示されます。

Paginationの設定
Paginationの設定

Postのタイトルをクリックするとこれまで通り詳細ページに移動することはできますが、NextボタンをクリックするとPaginationのリンク先が/posts/2になっているためPostの2の記事の詳細ページのURLと同じなので詳細ページが開いてしまい、Paginationが正しく動作しません。

ルーティングには優先順位があり[XXX].astroの方が[…XXX].astroより優先的に表示されます。/posts/2の場合は[id].astroのページが表示されます。

詳細ページへのリンクはPaginationのURLと重複しないようにidではなく別のものに変更するためここではtitleを利用します。ファイル名[id].astroとしていましたがファイル名を[title].astroに変更します。


---
import type { Post } from './[...page].astro';
export async function getStaticPaths() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const posts: Post[] = await response.json();

  return posts.map((post) => {
    return {
      params: { title: post.title },
      props: { post },
    };
  });
}

const { post } = Astro.props;
---

<h1>{post.title}</h1>
<div>
  {post.body}
</div>

[…page].astroから設定していた各ページへのリンクをpage.idからpage.titleに変更します。


---
import Layout from '../../layouts/Layout.astro';
import type { Page, GetStaticPathsOptions } from 'astro';

export type Post = {
  useId: number;
  id: number;
  title: string;
  body: string;
};

export interface Props {
  page: Page<Post>;
}

export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const posts = await response.json();
  return paginate(posts, { pageSize: 5 });
}

const { page } = Astro.props;
---

<Layout title="Post一覧ページ">
  <h1>Post一覧</h1>
  <ul>
    {
      page.data.map((post) => (
        <li>
          <a href={`/posts/${post.title}`}>{post.title}</a>
        </li>
      ))
    }
  </ul>
  {page.url.prev ? <a href={page.url.prev}>Previous</a> : null}
  {page.url.next ? <a href={page.url.next}>Next</a> : null}
</Layout>

再度Nextボタンをクリックすると次のページに移動することができるようになりました。Nextだけではなく元のページに戻れるようにPreviousのリンクも表示されています。

Paginationにより次のページに移動
Paginationにより次のページに移動

Postのリンクをクリックしても個別ページが表示されます。URLがIDではなくtitleが利用されています。

詳細ページのURLにタイトル
詳細ページのURLにタイトル

このようにAstroではPaginationも簡単に実装することができます。

Markdownのの利用

pagesフォルダの中にabout.astroを保存するだけでルーティングが設定されたようにMarkdownファイルをpagesフォルダに保存してもルーティングが自動で行われMarkdownに記述した内容がブラウザ上に表示されます。

動作確認のためpagesフォルダにmarkdown.mdファイルを作成します。


---
title: AstroでMarkDown
---

# AstroでMarkDown

はじめてAstroを利用してMarkDownを書いています。

ブラウザから/markdownにアクセスするとmarkdown.mdファイルに記述したものであることがわかります。Markdownで指定した通り文字も大きく表示されています。

マークダウンで表示
マークダウンで表示

Layoutファイルの設定

MarkdownファイルでもLayoutファイルを利用することができます。layoutsフォルダにMdLayouts.astroファイルを作成して以下を記述します。Markdownに記述した内容はslotに挿入されます。Astroのpropsを通してFront Matterに記述した内容にアクセスすることができます。


---
const { content } = Astro.props;
---
<html>
  <head>
    <title>{content.title}</title>
    <meta charset="UTF-8">
  </head>
  <body>
    <slot />
  </body>
</html>
propsのcontentの中にはFront MatterだけではなくMarkdownファイルの中身も入っています。
fukidashi

作成したLayoutファイルを利用するためにはMarkdownファイルのFrontMatterで作成したレイアウトファイルを指定する必要があります。


---
layout: ../layouts/MdLayout.astro
title: AstroでMarkDown
---

# AstroでMarkDown

はじめてAstroを利用してMarkDownを書いています。

再度ブラウザで確認すると表示されている内容に変化はありませんがtitleの設定した値はブラウザのタブに反映されていることがわかります。

Markdown用のレイアウトの設定
Markdown用のレイアウトの設定

ビルドを実行するとdist/markdown/index.htmlが作成され下記の内容に変換されます。


<!DOCTYPE html>
<html>
  <head>
    <title>AstroでMarkDown</title>
    <meta charset="UTF-8" />
  </head>
  <body>
    <h1 id="astro-で-markdown">Astro で MarkDown</h1>
    <p>はじめて Astro を利用して MarkDown を書いています。</p>
  </body>
</html>

Markdownの中身を取得

pagesフォルダに保存したmarkdown.mdファイルの中身を確認したい場合にはAstro.globを利用することができます。index.astroファイルを利用して動作確認を行います。


---
import Layout from '../layouts/Layout.astro';
import Card from '../components/Card.astro';

const markdowns = await Astro.glob('./*.md');
markdowns.map((mark) => {
  console.log(mark.rawContent());
});
---
//略

実行すると”npm run dev”コマンドを実行したターミナルにmarkdown.mdファイルの内容が表示されます。


# Astro で MarkDown

はじめて Astro を利用して MarkDown を書いています。

Astro1.0ではAstro.globを利用したMarkdownファイルからコンテンツやfrontmatterの情報を取得していましたがAstro2.0以降からはContent Collectionsを利用して行います。

Content Collectionsの設定

Astro2.0から追加されたContent CollectionsはMarkdownファイルやMDXファイルを管理するための機能です。

Content Collectionsではfrontmatterのスキーマ定義を行うことでfrontmatterに設定するプロパティ(title, slug, descriptionなど)が決まり, frontmatterのプロパティに設定する値に対してバリデーションを行うことができます。titleの型がstring(文字列)だとスキーマ定義を行った場合にMarkdownファイルのfrontmatterでtitleを文字列で設定しなければエラーになります。

さらにfrontmatterの型が自動で生成されるためコード上でfrontmatterのプロパティを利用したい場合にはAutocompleteで簡単に設定することができます。説明がわかりにくいと思いますが実際に動作確認を行えば難しいものではないので確認していきます。

contentフォルダの作成

Content Collectionsを利用するためには必ずsrcフォルダの下にcontentフォルダが必要となるためcontentフォルダを作成します。contentフォルダの下に任意の名前のフォルダを作成します。ここではblogという名前のフォルダを作成してその下にMarkdownファイルを作成します。blog以外にもdocやnewsletterなどの任意の名前のフォルダを複数作成することができその下にMarkdownファイルを作成することでそれぞれが独立したコンテンツのグループとして管理することができます。blogフォルダに保存したグループのことをblog Collection, docフォルダに保存したグループのことをdoc Collectionという呼び方をします。

Markdownファイルの作成

content/blogの下にブラウザ上に公開したいMarkdownファイルastro.mdを作成します。astro.mdファイルの内容は下記の通りです。frontmatterはtitle, author, description, publishDateプロパティで構成されています。


---
title: Astro2.0 の Content Collections の設定方法
author: John
description: Astro2.0 で追加された Content Collections の設定方法について説明を行っています。
publishDate: 2024/08/31
---

# Astro のインストール

Astro のインストールを行います。

config.tsファイルの作成

src/contentフォルダの下にconfig.tsファイルを作成します。config.tsファイルの作成は必須ではありませんが作成することでfrontmatterのスキーマを定義してバリデーションを行うことができ型の自動生成を行うことができます。

config.tsファイルではzとdefineCollectionを利用してfrontmatterのスキーマの定義を行います。zはZodというバリデーションライブラリを表しています。


import { z, defineCollection } from 'astro:content';
const blog = defineCollection({
   type: 'content',
  schema: z.object({
    title: z.string(),
    author: z.string(),
    description: z.string(),
    publishDate: z.string().transform((str:string) => new Date(str)),
  }),
});
export const collections = {
  blog,
};

Zodのバリデーションライブラリの利用方法については下記の記事で公開しています。

定義したスキーマはsrc/blog/astro.mdファイルのfrontmatterのmetadataと一致します。title, author, description, publishDateはすべてstringを設定しています。titleにz.string()と設定することでtitleには文字列が入ることを定義しています。

プロジェクトフォルダ直下には.astroフォルダが存在し、その中にはtypes.d.tsファイルがあります。


/// <reference types="astro/client" />

config.tsファイルを作成して保存しnpm run devコマンドを実行します。実行するとtypes.d.tsファイルが更新され.astroフォルダの下にastroフォルダとcollectionsフォルダが作成されます。


/// <reference types="astro/client" />
/// <reference path="astro/content.d.ts" />

型のエラーメッセージが表示されたり.astroフォルダ下のファイルを手動で更新したい場合には”npx astro sync”コマンドを実行してください。

Markdownの内容を取得

ブラウザからhttp://localhost:4321にアクセスした場合に作成したMarkdownファイルの内容が表示されるようにsrcのindex.astroファイルにMarkdownに記述した内容を取得するためのコードを記述していきます。

getCollection関数を利用し引数にconfig.tsファイルで定義したblog collectionを指定します。getCollectionを利用するだけでMarkdownファイルの内容を取得することができます。


---
import Layout from '../layouts/Layout.astro';
import { getCollection } from 'astro:content';

const blogs = await getCollection('blog');
console.log(blogs)
---

Content Script(fronmatter)の中でconsole.logを実行しているのでblogsの中身はnpm run devコマンドを実行したターミナルに表示されます。配列になっているので複数のMarkdownファイルが存在する場合には複数のオブジェクトが保存されることになります。現在は1つのMarkdownファイルしか存在しないため配列には1つのオブジェクトが入っています。


[
  {
    id: 'astro.md',
    slug: 'astro',
    body: '\n# Astro のインストール\n\nAstro のインストールを行います。\n',
    collection: 'blog',
    data: {
      title: 'Astro2.0 の Content Collections の設定方法',
      author: 'John',
      description: 'Astro2.0 で追加された Content Collections の設定方法について説明を行っています。',
      publishDate: 2024-08-31T15:00:00.000Z
    },
    render: [AsyncFunction: render]
  }
]

記事のtitleはdataオブジェクトのtitleプロパティに含まれていることがわかるのでその値を利用して記事のタイトルを表示させます。blog.dataと打つだけで候補が出てくるので簡単に設定を行うことができます。config.tsファイルを作成していない場合にはfrontmatterのスキーマの情報から作成される型を利用できないため候補が表示されることはありません。

dataプロパティの候補表示
dataプロパティの候補表示

記事一覧にリンクを設定するためslugを利用します。slugの値はファイルから自動設定されています。


---
import Layout from '../layouts/Layout.astro';
import { getCollection } from 'astro:content';

const blogs = await getCollection('blog');
---

<Layout title="top">
  <h1>ブログ記事一覧</h1>
  <ul>
    {
      blogs.map((blog) => (
        <li>
          <a href={`/blog/${blog.slug}`}>{blog.data.title}</a>
        </li>
      ))
    }
  </ul>
</Layout>

ブラウザで確認すると記事のタイトルが表示されます。

記事一覧の表示
記事一覧の表示

リンクをクリックしてもページが存在しないためpagesフォルダに404.astroファイルを作成していない場合は”404 Not Found”ページが表示されます。

ページを作成するためにsrcフォルダにblogフォルダを作成し、[slug].astroファイルを作成します。slugの値は記事によって異なるのでDynamic Routingの設定を行います。

Dynamic RoutingなのでgetStaticPathsを利用して設定を行います。デフォルトのStatic outputにより静的ファイルを作成する場合はビルド時にすべてのページを作成する必要があるのですべての記事に対するパス(URL)が必要となります。その時にgetStaticPathsを利用します。記事の内容についてはrender関数を実行することで取得できるContentコンポーネントを利用します。PropsのTypeについてはCollectionEntryを利用することで設定することができます。PropsのTypeを設定していない場合にはdataプロパティが持つ値の候補が表示されることはありません。


---
import {  getCollection } from 'astro:content';
import type { CollectionEntry } from 'astro:content';
import Layout from '../../layouts/Layout.astro';

export async function getStaticPaths() {
  const blogs = await getCollection('blog');

  return blogs.map((blog) => {
    return {
      params: { slug: blog.slug },
      props: { blog },
    };
  });
}

interface Props {
  blog: CollectionEntry<'blog'>;
}

const { blog } = Astro.props;
const { Content } = await blog.render();
---

<Layout title={blog.data.title}>
  <h1>{blog.data.title}</h1>
  <div>
    <Content />
  </div>
</Layout>

Static OutputではなくSSRモードを利用した場合はビルド時に静的ファイルの作成は行わないためgetStaticPathsは利用しません。
fukidashi

[slug].astroファイルを作成後にリンクをクリックすると記事の詳細ページが表示されます。

Markdownファイルの中身を表示
Markdownファイルの中身を表示

Content Collectionsを利用することでMarkdownファイルに記述した内容を簡単にブラウザ上に表示させることができました。

バリデーションエラー

astro.mdファイルを複製して以下のMarkdownファイルを作成します。titleのみ変更します。


---
title: Zodによるバリデーション
author: John
description: Astro2.0 で追加された Content Collections の設定方法について説明を行っています。
publishDate: 2023/01/25
---

# Astro のインストール

Astro のインストールを行います。

ブラウザからlocalhost:3000にアクセスすると記事一覧に作成した記事のタイトルが追加されます。

Markdownファイルの追加
Markdownファイルの追加

config.tsによりfrontmatterのスキーマ定義を行っているのでmarkdownファイルのfrontmatterの中の設定値がauthorではなくautherと記述されている場合にはエラーが表示されます。

frontmatterのmetadataの誤りによるエラー
frontmatterのmetadataの誤りによるエラー

またauthorに値を設定しなかった場合にはauthorの値がstringではないためエラーが表示されます。

authorに値を指定しない場合
authorに値を指定しない場合

Markdownのauthorの設定が必須ではない場合にはスキーマの定義でoptionalを利用することでauthorを設定しなくても(Markdownのauthorの行を削除、値を設定しないことではない)エラーが表示されることはなくなります。


const blog = defineCollection({
  schema: z.object({
    title: z.string(),
    author: z.string().optional(),
    description: z.string(),
    publishDate: z.string().transform((str) => new Date(str)),
  }),
});

そのほかにもauthorが”John”, “Kevin”しか存在しない場合にはenumを利用することができます。


const blog = defineCollection({
  schema: z.object({
    title: z.string(),
    author: z.enum(['John', 'Kevin']),
    description: z.string(),
    publishDate: z.string().transform((str) => new Date(str)),
  }),
});

“John”, “Kevin”以外の名前をauthorに設定するとエラーが表示されます。

authorにenumを利用
authorにenumを利用

このようにconfig.tsファイルでZodによるスキーマ定義を行うことで誤ったfontmatterを設定して記事を公開するということがなくなります。

Paginationの設定

Content Collectionを利用している場合もAstroのpaginate関数を利用することでpaginationの設定を行うことができます。設定方法は外部リソースからデータを取得してpaginationを設定した時と同じです。

現在Markdownファイルが2つしかないのでsrc/content/blogフォルダ内のMarkdownファイルを動作確認のため複製しておきます。ファイル名はslugに利用するので重複しない名前をつけてください。

Dynamic Routingの設定に変更する必要があるためsrcフォルダのindex.astroファイルの名前を[…page].astroに変更します。


---
import Layout from '../layouts/Layout.astro';
import { CollectionEntry, getCollection } from 'astro:content';
import type { GetStaticPathsOptions, Page } from 'astro';

export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
  const blogs = await getCollection('blog');
  return paginate(blogs, { pageSize: 2 });
}

interface Props {
  page: Page<CollectionEntry<'blog'>>;
}

const { page } = Astro.props;
---

<Layout title="top">
  <h1>ブログ記事一覧</h1>
  <ul>
    {
      page.data.map((blog) => (
        <li>
          <a href={`/blog/${blog.slug}`}>{blog.data.title}</a>
        </li>
      ))
    }
  </ul>
  {page.url.prev ? <a href={page.url.prev}>Previous</a> : null}
  {page.url.next ? <a href={page.url.next}>Next</a> : null}
</Layout>

Previos, Nextのリンクが表示されクリックすると表示される内容が変わります。Markdownの複製時にtitleを変更していないので同じ名前が表示されています。

paginationの設定
paginationの設定
動作確認時に2番目のページのみ”Previous”の値が空白になる問題がありました。
fukidashi

SSRの動作確認

AstroではSSR(Server Side Rendring)を利用することができます。SSRを利用するためにはアクセスがあった場合にサーバ側で処理を行う必要があるためServer Runtime(サーバ側でJavaScriptを動かす環境)が必要です。各Server Runtimeに対応したAdapterが提供されているのでAdapterをインストールすることでSSRが利用できます。AdapterにはAstro公式とコミュニティー管理のAdapterが存在します。Astro公式の利用できるAdapterについては下記の通りです。本文書ではNode.jsを利用して行います。

  • Cloudflare
  • Deno
  • Netlify
  • Node.js
  • Vercel

そのほかのAdapterについてはドキュメントから確認することができます。

動作環境の確認

SSRの動作確認を行うために動作確認を行う前のファイル構成を確認しておきます。ファイルの内容はこれまで本文書で設定した内容です。

Content Collections APIを利用しているのでsrc/content/blogフォルダにはMarkdownファイルのastro.md, zod.mdを保存しています。config.tsファイルのスキーマ設定は下記の通りです。


import { z, defineCollection } from 'astro:content';
const blog = defineCollection({
  type: 'content',
  schema: z.object({
    title: z.string(),
    author: z.enum(['John', 'Kevin']),
    description: z.string(),
    publishDate: z.string().transform((str) => new Date(str)),
  }),
});
export const collections = {
  blog,
};

pagesフォルダのindex.astroファイルは下記の通りです。getCollection関数を利用してMarkdownファイルの内容を取得しています。


---
import Layout from '../layouts/Layout.astro';
import { getCollection } from 'astro:content';

const blogs = await getCollection('blog');
---

<Layout title="top">
  <h1>ブログ記事一覧</h1>
  <ul>
    {
      blogs.map((blog) => (
        <li>
          <a href={`/blog/${blog.slug}`}>{blog.data.title}</a>
        </li>
      ))
    }
  </ul>
</Layout>

page/blogフォルダの下の個別記事を表示するための[slug].astroファイルは下記の通りです。


---
import {  getCollection } from 'astro:content';
import type { CollectionEntry } from 'astro:content';
import Layout from '../../layouts/Layout.astro';

export async function getStaticPaths() {
  const blogs = await getCollection('blog');

  return blogs.map((blog) => {
    return {
      params: { slug: blog.slug },
      props: { blog },
    };
  });
}

interface Props {
  blog: CollectionEntry<'blog'>;
}

const { blog } = Astro.props;
const { Content } = await blog.render();
---

<Layout title={blog.data.title}>
  <h1>{blog.data.title}</h1>
  <div>
    <Content />
  </div>
</Layout>

ブラウザでアクセスするとブログ一覧が表示されます。

Markdownファイルの追加
Markdownファイルの追加

ブログ一覧に表示されているタイトルをクリックすると詳細画面が表示されます。

Markdownファイルの中身を表示
詳細画面の表示

SSRの設定(Node.js)

Node.jsのAdapterのインストールを行います。


 % npx astro add node
✔ Resolving packages...

  Astro will run the following command:
  If you skip this step, you can always run it yourself later

 ╭───────────────────────────────────╮
 │ npm install @astrojs/node@^8.3.3  │
 ╰───────────────────────────────────╯

✔ Continue? … yes
✔ Installing dependencies...

  Astro will make the following changes to your config file:

 ╭ astro.config.mjs ─────────────────────────────╮
 │ import { defineConfig } from 'astro/config';  │
 │                                               │
 │ import node from "@astrojs/node";             │
 │                                               │
 │ // https://astro.build/config                 │
 │ export default defineConfig({                 │
 │   output: "server",                           │
 │   adapter: node({                             │
 │     mode: "standalone"                        │
 │   })                                          │
 │ });                                           │
 ╰───────────────────────────────────────────────╯

  For complete deployment options, visit
  https://docs.astro.build/en/guides/deploy/

✔ Continue? … yes
  
   success  Added the following integration to your project:
  - @astrojs/node

インストール後に設定ファイルであるastro.config.mjsファイルを見るとoutputとserverが設定されていることがわかります。


import { defineConfig } from 'astro/config';

import node from "@astrojs/node";

// https://astro.build/config
export default defineConfig({
  output: "server",
  adapter: node({
    mode: "standalone"
  })
});;

Adapterをインストール後に”npm run dev”コマンドで開発サーバを起動してアクセスを行うとコマンドを実行したターミナルにはWARNINGが表示されます。


09:28:58 [WARN] [router] getStaticPaths() ignored in dynamic page /src/pages/blog/[slug].astro. Add `export const prerender = true;` to prerender the page as static HTML during the build process.
09:28:58 [ERROR] Cannot read properties of undefined (reading 'render')

デフォルトのStatic Site Genaratorとして利用する場合はビルド時に存在するページを作成する必要があるためgetStaticPathsを利用しますがSSRの場合はアクセス時にページを生成するため事前にPathを設定する必要がありません。

SSRを利用した場合の設定方法についてはドキュメントに記載されているので参考に設定を行います。

SSR利用時の設定方法
SSR利用時の設定方法

getCollection関数によって一括に取得するのではなくgetEntry関数の引数にslugを利用することで指定したslugを持つMarkdownファイルのみ取得しています。


---
import { getEntry } from 'astro:content';
import Layout from '../../layouts/Layout.astro';

const { slug } = Astro.params;

const blog = await getEntry('blog', slug!);

if (blog === undefined) {
  return Astro.redirect("/404");
}

const { Content } = await blog.render();
---

<Layout title={blog.data.title}>
  <h1>{blog.data.title}</h1>
  <div>
    <Content />
  </div>
</Layout>

[slug].astroを更新するとエラーは解消して個別ページにアクセスすることが可能になります。slugが正しい場合にはページは表示されますが存在しないslugにアクセスした場合には404ページが表示されます。getEntryでslugに対応するページが存在しない場合にはblogの値がundefinedになるため分岐を利用して404ページのURLにリダイレクトさせています。

存在しないslugでアクセスした場合には404にリダイレクトされ404.astroの内容が表示されます。

404ページのカスタマイズ
404ページのカスタマイズ

404.astroのカスタムページが存在しない場合にはデフォルトの404ページが表示されます。

デフォルトの404ページ
デフォルトの404ページ

Hybrid Rendring

SSRのAdapterを設定するとすべてのページがSSRとして動作しますがastro.config.mjsファイルのoutputの値でデフォルトの動作を変更することができます。2つのモードが存在し、outputでは”server”, “hybrid”を選択することができます。Adapterをインストールすると自動で”server”が設定されています。”server”の場合はすべてのページがSSRとして動作し、あるページをStaticなページとして表示したい場合にはprerender変数の値をtrueに設定する必要があります。”hybrid”の場合はその逆ですべてページがStaticなページとして動作するのでSSRとして動作させたい場合にはprerenderの値をfalseに設定します。

[slug].astroはoutputをserverに変更することでgetStaticPaths関数を利用しないコードに変更を行いました。prerenderの値をtrueに設定します。


---
import { getEntry } from 'astro:content';
import Layout from '../../layouts/Layout.astro';

export const prerender = true;
//略

prerenderの値をtrueに設定すると開発サーバを起動したコンソールにはエラーが表示されます。daynamic routesには”getStaticPaths”関数を利用する必要があると言っています。prerenderの値をtrueにすることでSSRではなくAstroのデフォルトであるStaticなページとして動作することがわかります。


[ERROR] [GetStaticPathsRequired] `getStaticPaths()` function is required for dynamic routes. Make sure that you `export` a `getStaticPaths` function from your dynamic route.
  Hint:
    See https://docs.astro.build/en/guides/routing/#dynamic-routes for more information on dynamic routes.
    
    Alternatively, set `output: "server"` or `output: "hybrid"` in your Astro config file to switch to a non-static server build. This error can also occur if using `export const prerender = true;`.
    See https://docs.astro.build/en/guides/server-side-rendering/ for more information on non-static rendering.

[slug].astroファイルをgetStaticPaths関数を利用したコードに戻します。


---
import {  getCollection } from 'astro:content';
import type { CollectionEntry } from 'astro:content';
import Layout from '../../layouts/Layout.astro';

export const prerender = true;

export async function getStaticPaths() {
  const blogs = await getCollection('blog');

  return blogs.map((blog) => {
    return {
      params: { slug: blog.slug },
      props: { blog },
    };
  });
}

interface Props {
  blog: CollectionEntry<'blog'>;
}

const { blog } = Astro.props;

const { Content } = await blog.render();
---

<Layout title={blog.data.title}>
  <h1>{blog.data.title}</h1>
  <div>
    <Content />
  </div>
</Layout>

エラーが解消して詳細ページにアクセスすることが可能になります。

動作確認が完了したら[slug].astroファイルで設定したprerenderの行を削除してgetStaticPathsを利用しない状態に戻しておきます。


---
import { getEntry } from 'astro:content';
import Layout from '../../layouts/Layout.astro';

const { slug } = Astro.params;

const blog = await getEntry('blog', slug!);

if (blog === undefined) {
  return Astro.redirect("/404");
}

const { Content } = await blog.render();
---

<Layout title={blog.data.title}>
  <h1>{blog.data.title}</h1>
  <div>
    <Content />
  </div>
</Layout>

ビルドの実行

SSRを設定した場合にビルドを実行した場合どのようなファイルが作成されるのか確認します。


% npm run build  

> @example/basics@0.0.1 build
> astro check && astro build

10:52:36 [build] output target: server
10:52:36 [build] deploy adapter: @astrojs/node
10:52:36 [build] Collecting build info...
10:52:36 [build] Completed in 28ms.
10:52:36 [build] Building server entrypoints...
10:52:39 [build] Completed in 2.95s.

 finalizing server assets 

10:52:39 [build] Rearranging server assets...
10:52:39 [build] Server built in 2.99s
10:52:39 [build] Complete!

実行後に作成されるdistフォルダの中身を確認するとhtmlファイルではなくmjsファイルが作成されていることがわかります。

SSRでビルド後に作成されるフォルダとファイル
SSRでビルド後に作成されるフォルダとファイル

全てのページファイルがSSRとして動作しているのでindex.astroのみデフォルトのstaticと変更するためにprerenderの値をtrueに設定します。


---
import Layout from '../layouts/Layout.astro';
import { getCollection } from 'astro:content';
export const prerender = true;

const blogs = await getCollection('blog');
---
//略

npm run buildコマンドを実行してdistフォルダの中身を確認するとclientフォルダにindex.htmlファイルが作成されていることがわかります。

prerender設定によるindex.htmlファイルの生成
prerender設定によるindex.htmlファイルの生成

/blog/[slug].astroファイルでprerenderの値をtrueに設定してbuildを実行するとdistのclientフォルダにはblogフォルダが作成されその下にastro, zodフォルダと各フォルダの下にindex.htmlファイルの静的ファイルが作成されます。

ローカルでのSSRの動作確認とprerenderの値によってページ毎にSSRを切り替えることができることがわかりました。

データベースへの接続

Astroからデータベースへの接続は可能なのかどうか気になっている人もいるかと思います。本文書ではPrismaを利用してSQLiteデータベースへの接続を行います。データベースのスキーマを定義することでSQLではなくオブジェクトのメソッドを利用してデータベースを操作することができます。データベースのテーブルも定義したスキーマを元にコマンドで一つで作成することができます。

Prismaの設定

Prismaのインストールを行います。


 % npm install prisma --save-dev

インストール後にPrismaの設定ファイルを作成するために初期化コマンド(npx prisma init)を実行します。接続するデータベースがSQLiteなのでオプションの–datasource-provider sqliteをつけます。


 % npx prisma init --datasource-provider sqlite

コマンドを実行するとprismaフォルダと.envファイルが作成されます。prismaフォルダにはPrismaの設定ファイルschema.prismaが作成されます。schema.prismaファイルにデータベースへの接続情報やデータモデルを記述します。データモデルはテーブルの構成情報です。


// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

SQLiteの設定になっているので上記の設定はそのままでデータモデルを記述します。データモデルの記述はデータベースによって異なるのでテーブルに追加した列の型の設定方法やリレーションシップなど不明な場合はPrismaのドキュメントを参考に設定を行ってください。

ここではTodoテーブルを作成するので以下を記述します。Todoテーブルはid, name, isCompleteの3つの列を持ち、idは自動で順番に番号が付与されます。nameはTodoの名前で文字列、isCompleteはTodoが完了したかどうかを表し、trueやfalseのboolean値のみ設定できます。


generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Todo {
  id      Int      @id @default(autoincrement())
  name   String
  isComplete Boolean @default(value: false)
}

schema.prismaの設定が完了したらデータベースファイル(SQLiteはファイルベースのデータベース)とテーブルを作成するためnpx prisma db pushコマンドを実行します。コマンドを実行するとprismaフォルダにdev.dbファイルが作成されます。


 % npx prisma db push

テーブルが作成できているかどうかはPrisma Studioを利用することができます。SQLiteのデータベースへの接続はコマンドによる接続やデータベースの管理ソフト(例:TablePlus)によっても行うことができます。


 % npx prisma studio
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Prisma Studio is up on http://localhost:5555

Prisma Studioからはデータの閲覧だけではなくデータの追加もできるので2件のデータ追加を行います。Prisma Studioからはデータの追加だけではなく削除や更新も可能です。

保存したデータの確認
保存したデータの確認

Prismaの設定は完了です。

データの取得

データベースのTodoテーブルに保存されたデータをAstroから取得する方法を確認します。srcフォルダにlibフォルダを作成してその下にdb.tsファイルを作成します。Prismaクライアントのインスタンスを作成しています。


import { PrismaClient } from '@prisma/client';

export const prisma = new PrismaClient();

pagesフォルダのindex.astroファイルにデータ取得のコードを記述します。


---
import Layout from '../layouts/Layout.astro';
import { prisma } from '../lib/db';

const todos = await prisma.todo.findMany();
---

<Layout title="top">
  <h1>Todo一覧</h1>
  <ul>
    {todos.map((todo) => <li>{todo.name}</li>)}
  </ul>
</Layout>

開発サーバ(npm run dev)コマンドを実行してhttp://localhost:3000にアクセスするとデータベースから取得したデータが表示されます。

取得したデータの確認
取得したデータの確認

Prismaを経由してSQLiteデータベースに保存したデータをAstroから取得して表示することができました。

astro previewコマンドにSSRの動作確認

npm run build後にnpm run previewコマンドを実行することでローカル環境でbuildで作成されたdistフォルダのコードで動作確認を行うことができます。現在はNodeのAdapterのみSSRビルドのpreviewをサポートしているのでprerenderを利用してSSR利用時と利用していないデフォルトの違いを確認します。

prerenderの値をtrueに設定(SSR設定なしのでデフォルト)します。


---
import Layout from '../layouts/Layout.astro';
import { prisma } from '../lib/db';

export const prerender = true;

const todos = await prisma.todo.findMany();
---

<Layout title="top">
  <h1>Todo一覧</h1>
  <ul>
    {todos.map((todo) => <li>{todo.name}</li>)}
  </ul>
</Layout>

npm run buildコマンドを実行して、npm run previewコマンドを実行してください。メッセージを見るとsrc/pages/index.astroからindex.htmlが作成されていることがわかります。


 % npm run build

> @example/basics@0.0.1 build
> astro build

11:04:31 [build] output target: server
11:04:31 [build] deploy adapter: @astrojs/node
11:04:31 [build] Collecting build info...
11:04:31 [build] Completed in 20ms.
11:04:31 [build] Building server entrypoints...
11:04:34 [build] Completed in 2.87s.

 prerendering static routes 
Server listening on http://127.0.0.1:3000
▶ src/pages/index.astro
  └─ /index.html (+60ms)
▶ src/pages/blog/[slug].astro
  ├─ /blog/astro/index.html (+12ms)
  └─ /blog/zod/index.html (+19ms)
Completed in 196ms.


 finalizing server assets 

11:04:34 [build] Rearranging server assets...
11:04:34 [build] Server built in 3.11s
11:04:34 [build] Complete!
% npm run preview

> @example/basics@0.0.1 preview
> astro preview

Preview server listening on http://localhost:3000

この状態でPrisma Studioを利用してTodoテーブルに新たに行を追加してください。ビルド時にindex.astroファイルはindex.htmlファイルとして静的ファイルとして作成されているのでビルド後にデータベースに追加を行っても何も変化はありません。index.htmlファイルの中身はdist/client/index.htmlファイルで確認できます。

次にprerenderの設定を行わない場合で動作確認を行います。


---
import Layout from '../layouts/Layout.astro';
import { prisma } from '../lib/db';

const todos = await prisma.todo.findMany();
---

<Layout title="top">
  <h1>Todo一覧</h1>
  <ul>
    {todos.map((todo) => <li>{todo.name}</li>)}
  </ul>
</Layout>

npm run buildコマンドとnpm run previewコマンドを実行します。メッセージを見るとstatic routesにおけるindex.astroのメッセージがなくなっています。


% npm run build  

> @example/basics@0.0.1 build
> astro build

11:10:40 [build] output target: server
11:10:40 [build] deploy adapter: @astrojs/node
11:10:40 [build] Collecting build info...
11:10:40 [build] Completed in 23ms.
11:10:40 [build] Building server entrypoints...
11:10:43 [build] Completed in 3.04s.

 prerendering static routes 
Server listening on http://127.0.0.1:3000
▶ src/pages/blog/[slug].astro
  ├─ /blog/astro/index.html (+49ms)
  └─ /blog/zod/index.html (+56ms)
Completed in 158ms.


 finalizing server assets 

11:10:44 [build] Rearranging server assets...
11:10:44 [build] Server built in 3.24s
11:10:44 [build] Complete!
% npm run preview

> @example/basics@0.0.1 preview
> astro preview

Preview server listening on http://localhost:3000

ブラウザをprerenderがtrueの時に追加したTodoが確認できます。

Todo一覧の確認
Todo一覧の確認

Prisma Studioを利用してTodoテーブルに行を追加します。追加後にブラウザのリロードを行ってください。追加したTodoが表示されていることがわかります。SSRによってアクセス毎に処理が行われていることが確認できます。

追加したTodoの確認
追加したTodoの確認

Integrationsによるフレームワーク/ライブラリの追加

ここまではAstro Componentのみを利用してきましたが他のフレークワークやライブラリを利用したい場合の設定方法について確認していきます。

Astro IntegrationsではReact、Vue.js、TailwindなどのAstro公式のIntegrationsだけではなくSEOやImageに関する非公式のIntegrationsも提供されています。Integrationsを追加することで機能を拡張することができます。

Reactの利用

ReactのIntegrationsはnpx astro add reactコマンドでインストールを行うことができます。yarn, pnpxコマンドでもインストールできます。


 % npx astro add react
✔ Resolving packages...

  Astro will run the following command:
  If you skip this step, you can always run it yourself later

╭──────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ npm install @astrojs/react @types/react-dom@^18.0.6 @types/react@^18.0.21 react-dom@^18.0.0              ││ react@^18.0.0                                                                                            │╰──────────────────────────────────────────────────────────────────────────────────────────────────────────╯

✔ Continue? … yes
✔ Installing dependencies...

  Astro will make the following changes to your config file:

 ╭ astro.config.mjs ─────────────────────────────╮
 │ import { defineConfig } from 'astro/config';  │
 │                                               │
 │ import react from "@astrojs/react";           │
 │                                               │
 │ // https://astro.build/config                 │
 │ export default defineConfig({                 │
 │   integrations: [react()]                     │
 │ });                                           │
 ╰───────────────────────────────────────────────╯

? Continue? › (Y/n)
  
   success  Added the following integration to your project:
  - @astrojs/react

コマンドを実行するとAstroの設定ファイルであるastro.config.mjsにreactの設定が追加されます。


import { defineConfig } from 'astro/config';

import react from "@astrojs/react";

// https://astro.build/config
export default defineConfig({
  integrations: [react()]
});

インストールするだけでReactが利用可能になったので動作確認のためcomponentsフォルダにCounter.jsxファイルを作成します。作成したCounterコンポーネントはボタンをクリックすると画面上のカウントが増減するだけのコンポーネントです。


import { useState } from 'react';

const Counter = () => {
  const [count, setCount] = useState(0);

  return (
    <>
      <h1>React Counter</h1>
      <div>{count}</div>
      <div>
        <button onClick={() => setCount(count + 1)}>increment</button>
        <button onClick={() => setCount(count - 1)}>decrement</button>
      </div>
    </>
  );
};

export default Counter;

作成したCounterコンポーネントをabout.astroファイルでimportします。


---
import Layout from '../layouts/Layout.astro';
import Counter from "../components/Counter"
---
<Layout title="Aboutページ">
  <h1>Astroについて</h1>
  <Counter />
</Layout>

ブラウザから/aboutにアクセスするとCounterコンポーネントで設定した内容が表示されます。Increment, decrementボタンをクリックしてください。どちらをクリックしてもカウントの数は変わりません。

Counterの表示
Counterの表示

Astroでは独自のディレクティブを持っておりクライアント側でのインタラクティブな操作を持つコンポーネントにはclient:loadディレクティブを設定する必要があります。設定しない場合は確認した通りJavaScriptのコードは動きません。


---
import Layout from '../layouts/Layout.astro';
import Counter from "../components/Counter"
---
<Layout title="Aboutページ">
  <h1>Astroについて</h1>
  <Counter client:load />
</Layout>

設定後はincrement, decrementボタンをクリックするとカウントの数が更新されます。

カウンターの増減の確認
カウンターの増減の確認

client:load以外にもclient:visibleなど:の後の設定値よって動作が変わるディレクティブがあります。例えばclient:visibleであればユーザのviewportに入った時にコンポーネントがloadされます。入っていない時にはloadされています。ブラウザのデベロッパーツールのネットワークタブを確認することで違いを確認することができます。

ビルドの実行

ディレクティブのclient loadを追加することによってクライアントでのインタラクティブな操作が実現できるようになった結果ビルド後に作成されるabout.htmlにどのような変化があったか確認します。

npm run buildコマンドを実行するとdistフォルダが作成されるのでabout/index.htmlファイルを確認します。Counterコンポーネントを追加する前にnpm run buildを実行した際はindex.htmlはHTMLのみで記述された下記の内容でした。

aboutページのファイルの中身を確認
aboutページのファイルの中身を確認

Conunterコンポーネントを追加してclient:loadディレクティブを追加すると下記のようなJavaScriptのコードが確認できます。

about.htmlファイルの内容の確認
about.htmlファイルの内容の確認

distフォルダにはclient.4fb2ea36.js, Couter.e0a91ce0.jsなどのJavaScriptファイルも作成されています。

postsフォルダの下には1,2,3,..100フォルダが作成され各フォルダの下にはindex.htmlファイルが作成されいます。
fukidashi

Counterコンポーネントを追加しclient:loadディレクティブを追加しなかった場合のビルド後のabout.htmlも確認しておきます。JavaScriptコードが含まれていないことが確認できます。JavaScriptのコードが存在しないのでボタンをクリックしても何も起こらないのも納得できます。

client:loadを設定していない場合
client:loadを設定していない場合

Astroではディレクティブのclient:load(JavaScriptをクライアント側で利用)を設定することでファイルのサイズが大きくなることがここまでの動作確認で理解できました。

Vue.jsの利用

ReactだけではなくVue.jsも利用できるように追加を行います。npx astro add reactコマンドでインストールを行うことができます。


 % npx astro add vue
✔ Resolving integrations...

  Astro will make the following changes to your config file:

 ╭ astro.config.mjs ─────────────────────────────╮
 │ import { defineConfig } from 'astro/config';  │
 │ import react from "@astrojs/react";           │
 │                                               │
 │ import vue from "@astrojs/vue";               │
 │                                               │
 │ // https://astro.build/config                 │
 │ export default defineConfig({                 │
 │   integrations: [react(), vue()]              │
 │ });                                           │
 ╰───────────────────────────────────────────────╯

✔ Continue? … yes

  Astro will run the following command:
  If you skip this step, you can always run it yourself later

 ╭──────────────────────────────────────────────────╮
 │ npm install --save-dev @astrojs/vue vue@^3.2.30  │
 ╰──────────────────────────────────────────────────╯

✔ Continue? … yes
✔ Installing dependencies...
  
   success  Added the following integration to your project:
  - @astrojs/vue

Astroの設定ファイルastro.config.mjsにはVue.jsの設定が追加されます。Reactを先に追加しておいたので両方が追加された形で表示されます。


import { defineConfig } from 'astro/config';
import react from "@astrojs/react";

import vue from "@astrojs/vue";

// https://astro.build/config
export default defineConfig({
  integrations: [react(), vue()]
});

React, Vue.jsのどちらかを一方を一つのプロジェクトで利用できるというわけではなくコンポーネント毎に別々のフレームワーク/ライブラリを利用することができます。

Componentsフォルダに新たにCounterVue.vueファイルを作成します。Reactの場合と同様に作成したCounterVueコンポーネントはボタンをクリックすると画面上のカウントが増減するだけのコンポーネントです。


<script setup>
import { ref } from 'vue';

const count = ref(0);
</script>
<template>
  <h1>Vue Counter</h1>
  <div>{{ count }}</div>
  <div>
    <button @click="count++">increment</button>
    <button @click="count--">decrement</button>
  </div>
</template>

about.astroコンポーネントでCounterVueコンポーネントをimportします。


---
import Layout from '../layouts/Layout.astro';
import Counter from "../components/Counter"
import CounterVue from "../components/CounterVue.vue"
---
<Layout title="Aboutページ">
  <h1>Astroについて</h1>
  <Counter client:load />
  <CounterVue client:load />
</Layout>

ブラウザから/aboutにアクセスするとReactとVueで作成したコンポーネントが表示されボタンをクリックするとカウンターの数が増減します。どちらも問題なく動作することが確認できます。

カウンターの動作確認
カウンターの動作確認

Svelteの場合

Svelteの場合もReact, Vue.jsと同様の方法で設定を行うことができます。

npx astro add svelteコマンドを実行します。


 % npx astro add svelte

componentsフォルダにCounterSvelte.svelteファイルを作成します。


<script>
  let count = 0;
</script>

<h1>Svelte Counter</h1>
<div>{count}</div>
<div>
  <button on:click={() => (count += 1)}>increment</button>
  <button on:click={() => (count -= 1)}>decrement</button>
</div>

CounterSvelteコンポーネントをimportすることでボタンをクリックするとカウント数が増減します。

Astroの特徴の一つである好きなフレームワークを利用できるということが確認できました。

Tailwindの場合

Tailwind CSSもnpx astro add tailwindコマンドでインストールすることができます。


 % npx astro add tailwind
✔ Resolving integrations...

  Astro will make the following changes to your config file:

 ╭ astro.config.mjs ─────────────────────────────╮
 │ import { defineConfig } from 'astro/config';  │
 │ import react from "@astrojs/react";           │
 │ import vue from "@astrojs/vue";               │
 │                                               │
 │ import tailwind from "@astrojs/tailwind";     │
 │                                               │
 │ // https://astro.build/config                 │
 │ export default defineConfig({                 │
 │   integrations: [react(), vue(), tailwind()]  │
 │ });                                           │
 ╰───────────────────────────────────────────────╯

✔ Continue? … yes

  Astro will run the following command:
  If you skip this step, you can always run it yourself later

 ╭───────────────────────────────────────────╮
 │ npm install --save-dev @astrojs/tailwind  │
 ╰───────────────────────────────────────────╯

✔ Continue? … yes
⠹ Installing dependencies...
✔ Installing dependencies...

  Astro will generate a minimal ./tailwind.config.cjs file.

✔ Continue? … yes
  
   success  Added the following integration to your project:
  - @astrojs/tailwind

インストール後Astroの設定ファイルであるastro.config.mjsファイルにTailwindの設定が追加されます。


import { defineConfig } from 'astro/config';
import react from "@astrojs/react";
import vue from "@astrojs/vue";

import tailwind from "@astrojs/tailwind";

// https://astro.build/config
export default defineConfig({
  integrations: [react(), vue(), tailwind()]
});

さらにtailwindの設定ファイルもtailwind.config.cjsファイルも作成されます。

追加の設定を何もしなくてもTailwindが利用できるためlocalhost:3000にアクセスするとh1タグのフォントサイズなどのデフォルトの値もTailwindによって解除されます。

Tailwindインストール後の画面
Tailwindインストール後の画面

h1タグの文字のサイズを大きくしたい場合はtailwindのclassを適用する必要があります。

Endpointsの設定

AstroではSSRを利用したServer Endpointsとビルド時に静的ファイルが作成されるStatic File Endpointsがあります。本書ではServer Endpointsを利用して動作確認を行なっていきます。Server Endpointsを利用するためにSSRの設定が必要です。

Server Endpoints(API Routes)

pagesフォルダにapiフォルダを作成してtodos.tsファイルを作成します。todoファイルを作成することでURLのhttp://localhost:3000/api/todosエンドポイントが作成されアクセスすることが可能になります。

prismaを使って取得したデータが表示できるようにtodo.tsファイルを更新します。GETリクエストを設定したエンドポイントで受け取るためにget関数を利用します。


import type { APIRoute } from 'astro';
import { prisma } from '../../lib/db';

export const get: APIRoute = async () => {
  const todos = await prisma.todo.findMany();
  return new Response(JSON.stringify(todos), {
    status: 200,
    headers: {
      'Content-Type': 'application/json',
    },
  });
};

npm run devコマンドを実行して開発サーバを起動してブラウザからhttp://localhost:3000/api/todoにアクセスを行います。

JSONとしてブラウザに表示されていることが確認できます。

API Routesから取得したデータの確認
API Routesから取得したデータの確認

ブラウザから直接API Routesにアクセスを行いましたがindex.astroファイルからアクセスを行いデータの取得を行います。


---
import Layout from '../layouts/Layout.astro';
import type { Todo } from '@prisma/client';

const response = await fetch('/api/todo');
const todos: Todo[] = await response.json();
---

<Layout title="top">
  <h1>Todo一覧</h1>
  <ul>
    {todos.map((todo) => <li>{todo.name}</li>)}
  </ul>
</Layout>

ブラウザで確認するとAPI Routesで取得したデータ一覧が表示されます。

追加したTodoの確認
API Routesから取得したTodo一覧

API RoutesへのPOSTリクエスト

API Routesに対して送信されたPOSTリクエストの処理の動作確認はReactを利用して行います。

componentsフォルダの下にTodos.tsxファイルを作成します。Todos.tsxファイルで記述したReactの処理をクライアント側で実行するためclient:loadディレクティブを設定します。


---
import Layout from '../layouts/Layout.astro';
import Todos from '../components/Todos';
---

<Layout title="top">
  <h1>Todo一覧</h1>
  <Todos client:load />
</Layout>

POSTリクエストを送信する前にReactを利用してAPI Routesからデータが取得できるか確認します。useState, useEffect Hookを利用してfetch関数で取得したデータをtodosに保存して展開しています。


import type { Todo } from '@prisma/client';
import { useEffect, useState } from 'react';

const Todos = () => {
  const [todos, setTodos] = useState<Todo[]>([]);

  const getTodos = async () => {
    const response = await fetch('/api/todo');
    const data = await response.json();
    setTodos(data);
  };

  useEffect(() => {
    getTodos();
  }, []);

  return (
    <>
      <h1>Todo一覧</h1>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>{todo.name}</li>
        ))}
      </ul>
    </>
  );
};
追加したTodoの確認
Todo一覧

input要素を追加してinput要素に文字列を入力して”Enter”ボタンを押すとPOSTリクエストが送信されるように設定を行っています。入力した文字列はevent.target.valueから取得しています。


import type { Todo } from '@prisma/client';
import { useEffect, useState } from 'react';

const Todos = () => {
  const [todos, setTodos] = useState<Todo[]>([]);

  const getTodos = async () => {
    const response = await fetch('/api/todo');
    const data = await response.json();
    setTodos(data);
  };

  useEffect(() => {
    getTodos();
  }, []);

  const handleKeyDown = async (
    event: React.KeyboardEvent<HTMLInputElement>
  ) => {
    const target = event.target as HTMLInputElement;
    const name = target.value;

    if (event.key === 'Enter' && name) {
      console.log(name);
    }
  };

  return (
    <>
//略
    </>
  );
};

export default Todos;

表示されるinput要素に文字列を入力して”Enter”ボタンを押すとコンソールに入力した文字列が表示されることを確認します。

input要素への入力
input要素への入力

コンソールに文字列が表示されたらfetch関数を利用してPOSTリクエストを送信します。送信するURLはGETリクエストでTodo一覧を取得したものと同じです。送信するデータはinput要素で入力したnameです。


const handleKeyDown = async (
  event: React.KeyboardEvent<HTMLInputElement>
) => {
  const target = event.target as HTMLInputElement;
  const name = target.value;

  if (event.key === 'Enter' && name) {
    await fetch('/api/todo', {
      method: 'POST',
      headers: {
        'Content-Type': 'appication/json',
      },
      body: JSON.stringify({ name }),
    });
    target.value = '';
    getTodos();
  }
};

POSTリクエストを受け取るAPI Routes側の設定を行います。pages/apiのtodo.tsファイルを開いてpost関数を追加します。引数にはPOSTリクエストから送信したデータを取得するためにrequestを指定しています。request.json()から取り出したbodyオブジェクトからnameプロパティを取得してprismaのcreateメソッドでTodoテーブルへのデータ追加を行っています。


export const post: APIRoute = async ({ request }) => {
  const body = await request.json();
  const name = body.name;
  const todo = await prisma.todo.create({
    data: {
      name,
    },
  });
  return new Response(JSON.stringify(todo), {
    status: 200,
    headers: {
      'Content-Type': 'application/json',
    },
  });
};

設定後にinput要素に文字列を入力して”Enter”ボタンをクリックすると入力した文字がTodo一覧に追加されます。

入力したいTodoの追加
入力したいTodoの追加

API Routesを利用したPOSTリクエストの処理の設定方法を確認することができました。

Dynamic ルーティングの設定

DELETEメソッドの設定

表示されているTodo一覧からTodoを削除する方法を確認することでAPI RoutesでのDynamicルーティングの設定を行います。

Todoを削除する処理を追加するためにTodo名の横に”X”を追加してonClickイベントを設定します。”X”をクリックするとonClickイベントによりhandleDelete関数が実行されます。


//略
  const handleDelete = async (id: number) => {
    await fetch(`/api/todo/${id}`, {
      method: 'DELETE',
    });
    getTodos();
  };

  return (
    <>
      <h1>Todo一覧</h1>
      <div>
        <label id="name">Add Todo:</label>
        <input name="name" onKeyDown={handleKeyDown} />
      </div>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            {todo.name}
            <span
              style={{ paddingLeft: '0.5em', cursor: 'pointer' }}
              onClick={() => handleDelete(todo.id)}
            >
              X
            </span>
          </li>
        ))}
      </ul>
    </>
  );
};

export default Todos;

fetch関数で設定したURLはクリックするTodoによってidが変わるため動的に値が変わります。

pages/apiフォルダにtodoフォルダを作成して[id].tsファイルを作成します。ブラケットをidにつけることで動的に変わるidの値をparamsを利用して取得することができます。paramsから取り出したidを文字列から数値に変換してprismaのdeleteメソッドでidが一致するTodoをTodoテーブルから削除しています。


import type { APIRoute } from 'astro';
import { prisma } from '../../../lib/db';

export const del: APIRoute = async ({ params }) => {
  const id = Number(params.id);
  await prisma.todo.delete({ where: { id } });
  return new Response(null, {
    status: 200,
  });
};

設定完了後、Todo一覧に表示されている”X”をクリックするとクリックしたTodoが削除されます。

PATCHメソッドの設定

Dynamicルーティングを利用してDELETEメソッドの動作確認ができたので次はPATCHメソッドの方法を確認します。

Todo一覧のTodoの名前の横にチェックボックスを追加しonChangeイベントを設定します。チェックを行うとTodoのisCompleteの値がtrueならfalse, falseならtrueに変更されるようにhandleChange関数を追加します。isCompleteの値によってTodo名に横線が引かれるようにstyle属性の設定も行っています。


//略
  const handleChange = async (id: number, isComplete: boolean) => {
    await fetch(`/api/todo/${id}`, {
      method: 'PATCH',
      headers: {
        'Content-Type': 'appication/json',
      },
      body: JSON.stringify({ isComplete: !isComplete }),
    });
    getTodos();
  };

  return (
    <>
      <h1>Todo一覧</h1>
      <div>
        <label id="name">Add Todo:</label>
        <input name="name" onKeyDown={handleKeyDown} />
      </div>
      <ul>
        {todos.map((todo) => (
          <li
            key={todo.id}
            style={{
              textDecorationLine: todo.isComplete ? 'line-through' : '',
            }}
          >
            <input
              type="checkbox"
              onChange={() => handleChange(todo.id, todo.isComplete)}
              defaultChecked={todo.isComplete}
            />
            {todo.name}
            <span
              style={{ paddingLeft: '0.5em', cursor: 'pointer' }}
              onClick={() => handleDelete(todo.id)}
            >
              X
            </span>
          </li>
        ))}
      </ul>
    </>
  );
};

export default Todos;

設定後にブラウザでアクセスするとチェックボックスを確認することができます。

チェックボックスの表示確認
チェックボックスの表示確認

PATCHリクエストを処理できるようにAPI Routesの設定を行うため[id].tsファイルを更新します。patch関数を追加し、引数にはrequest, paramsを指定しています。requestからPATCHリクエストで送信されているデータを取得し、paramsからidを取得します。prismaのupdateメソッドを利用してidを持つtodoのisCompleteの値を更新しています。


import type { APIRoute } from 'astro';
import { prisma } from '../../../lib/db';

export const del: APIRoute = async ({ params }) => {
//略
};

export const patch: APIRoute = async ({ request, params }) => {
  const id = Number(params.id);
  const body = await request.json();
  const isComplete = body.isComplete;
  const todo = await prisma.todo.update({
    where: { id },
    data: { isComplete },
  });
  return new Response(JSON.stringify(todo), {
    status: 200,
    headers: {
      'Content-Type': 'application/json',
    },
  });
};

[id].tsファイルを更新後ブラウザを利用して動作確認を行います。チェックボックスにチェックを入れるとTodo名に横線が引かれることが確認できます。チェックを外すと横線は解除されます。

チェック後の動作確認
チェック後の動作確認

ここまでの処理でAPI RoutesにおけるCRUD(Create, Read, Update, Delete)の処理を確認することができました。

Cookiesの設定

AstroではCookiesを利用するための関数が準備されているのでどのようにCookiesを利用することができるのか確認していきます。Cookiesは認証(Authentification)にも利用することができます。

Astro.cookies

Cookiesの動作確認するためにドキュメントに記載されているコードをindex.astroファイルに記述します。Astro.cookies.getでcookieにアクセスすることができ、Astro.cookies.setでcookieを作成することができます。Astro.cookies.get(‘counter’)で戻されるcookieはAstroCookieオブジェクトでvalueプロパティを持っていますがnumberメソッドを利用することでvalueの値を文字列から数値に変換することができます。


---
import Layout from '../layouts/Layout.astro';
let counter = 0;

if (Astro.cookies.has('counter')) {
  const cookie = Astro.cookies.get('counter');
  counter = cookie.number() + 1;
}

Astro.cookies.set('counter', String(counter));
---

<Layout title="top">
  <h1>Cookie</h1>
  <div>Count:{counter}</div>
</Layout>

SSR(static site generator)に設定を行っていない場合には下記のエラーがメッセージが表示されます。CookiesはデフォルトのSSGモードの場合には利用することができないことがわかります。


[ssg] Headers are not exposed in static (SSG) output mode. To enable headers: set `output: "server"` in your config file.

astro.config.mjsファイルでoutputの値を’server’に設定してSSG(Server Side Rendering)のモードに設定後に再度確認を行うとブラウザのCookiesにcounterという名前でCookieが保存されていることがわかります。


export default defineConfig({
  output: 'server',
  adapter: node({
    mode: 'standalone',
  }),
  integrations: [react()],
});
Cookiesの確認
Cookiesの確認

有効期限がSessionなのでブラウザをリロードすると保存されているcookieのcounterの値が増えていくことを確認することができます。

cookieが保持されcounterの値が増えることを確認
cookieが保持されcounterの値が増えることを確認

個別のcookieの値であればAstro.cookies.get(cookie名)でアクセスすることができますがすべてのcookieを取得したい場合にはAstro.request.headers.get(‘cookies’)でアクセスすることができます。


Astro.cookies.set('counter', String(counter));
console.log(Astro.request.headers.get('cookie'));

npm run devコマンドを実行したターミナルにはcookieの名前と値が表示されます。


counter=3

クライアント(ブラウザ側)で実行されるscriptタグからcookieにアクセスすることも可能です。


//略
<script>
  console.log(document.cookie);
</script>

ブラウザのデベロッパーツールのコンソールにはcookieが表示されます。


counter=4

クライアント側のJavaScriptからアクセスさせないためにはcookieを作成する際のオプションによって設定が可能です。cookieのHttpOnly属性が設定されます。


Astro.cookies.set('counter', String(counter), {
  httpOnly: true,
});

API Routesでの設定

API Routesでのcookiesの設定方法について確認を行います。pages/apiフォルダにcookie.tsファイルを作成します。認証で利用することを想定してユーザIDを持つcookieを作成したい場合にはget関数の引数に含まれるcookiesを利用することができます。


import type { APIRoute } from 'astro';

export const get: APIRoute = async ({ cookies }) => {
    //認証処理
  cookies.set('user_id', '1', {
    path: '/',
    httpOnly: true,
    maxAge: 60 * 60 * 24 * 30,
  });
  return new Response(JSON.stringify({ message: 'success' }), {
    status: 200,
  });
};

クライアント側からAPI Routesの/api/cookieにアクセスを行います。


---
import Layout from '../layouts/Layout.astro';
let counter = 0;

if (Astro.cookies.has('counter')) {
  const cookie = Astro.cookies.get('counter');
  counter = cookie.number() + 1;
}

Astro.cookies.set('counter', String(counter), {
  httpOnly: true,
});
---

<Layout title="top">
  <h1>Cookie</h1>
  <div>Count:{counter}</div>
  <!-- <Test client:load /> -->
</Layout>
<script>
  fetch('/api/cookie')
    .then((res) => res.json())
    .then((data) => console.log(data));
</script>

ブラウザのコンソールにはAPI Routesから戻される{message:’success’}が表示されます。さらに保存されているcookiesを確認すると新たに作成したuser_idを確認することができます。API RoutesではMaxageを設定したので1ヶ月後の時間が設定されていることも確認できます。

追加されたCookieの確認
追加されたCookieの確認

cookieが保存されている場合に認証が完了している場合にcookieの値が存在するかどうかで認証が完了しているかどうかチェックすることも可能です。

index.astroファイルに/aboutへのリンクを設定してabout.astroでcookieを取得して認証しているかどうかのチェックを行います。


---
import Layout from '../layouts/Layout.astro';
---

<Layout title="top">
  <h1>Cookie</h1>
  <a href="/about">About</a>
</Layout>
<script>
  fetch('/api/cookie')
    .then((res) => res.json())
    .then((data) => console.log(data));
</script>

aboutページを作成するためにpagesフォルダにabout.astroファイルを作成します。


---
import Layout from '../layouts/Layout.astro';
let user_id = '';
if (Astro.cookies.has('user_id')) {
  const cookie = Astro.cookies.get('user_id');
  console.log(cookie);
  if (cookie.value) user_id = cookie.value;
}
---

<Layout title="top">
  <h1>About Page</h1>
  {user_id && <p>認証済み</p>}
</Layout>

/にアクセスを行い、cookieを作成した後、aboutページのリンクをクリックするとcookieにuser_idが保存されているので”認証済み”の文字列がブラウザ上に表示されます。

cookieが存在する場合
cookieが存在する場合

ブラウザのデベロッパーツールのApplicationタブのcookiesからuser_idのcookieを削除してaboutページに直接アクセスすると”認証済み”の文字列が表示されることはありません。

ログインフォームを作成してログイン認証が完了したユーザのみcookieを作成するように設定することで簡易的な認証を行うことができます。これはすべてSSR(Server Side Rendering)のページについてのcookieの利用でSSG(Static Site Generator)ページの話ではないので注意してください。

マイナーバージョンアップ

Astroのマイナーバージョンアップは頻繁に行われているので、最新バージョンにアップデートした場合には以下のコマンドを実行します。コマンドを実行することでAstroとすべての公式インテグレーションを最新バージョンに更新してくれます。


 % npx @astrojs/upgrade

 astro   Integration upgrade in progress.

      ◼  @astrojs/check is up to date on v0.9.3
      ◼  @astrojs/react is up to date on v3.6.2
      ◼  @astrojs/tailwind is up to date on v5.1.0
      ●  astro will be updated to v4.14.4

 ██████  Installing dependencies with npm...

+—————+  Houston:
| ^ u ^  Take it easy, astronaut!
+—————+

デプロイ

デプロイの方法についてはVercelを利用することで簡単に行うことができます。

方法についてはNext.jsをVercelにデプロイする方法と同じなのでデプロイする場合は参考にしてみてください。

まとめ

ビルドコマンドによって作成されるファイルの内容を確認することで冒頭で説明した”JavaScriptを可能な限り除いた静的なHTMLを作成する”という意味も理解できたかと思います。React, Vue.jsを追加インストールすることで好きなフレームワークを用いたコンポーネントの作成方法も理解することできました。