Qwik City でブログアプリを作る
Qwik City は Qwik のメタフレームワークです。React における Next.js、Vue.js における Nuxt.js のような関係と同等です。
Qwik City(またの名を Qwik Router)は Qwik のメタフレームワークです。React における Next.js、Vue.js における Nuxt.js のような関係と同等です。Qwik は長期的で安定したプリミティブに焦点を当てており、breaking changes なしで安定した状態を保つことができます。一方 Qwik City は大規模なサイトを構築するための意見とパフォーマンスの高い方法をもたらします。
Qwik City は Qwik のアーキテクチャのおかげで、余分な JavaScript をブラウザに配信することはありません。体感として、Remix のアーキテクチャによく似ているようにも感じます。
Qwik City のインストール
それでは早速 Qwik City でアプリケーションを作成しましょう。以下のコマンドから Qick City のテンプレートを作成できます。
npm create qwik@latest
starter を選択するように聞かれるので「Basic」を選択しました。
🐰 Let's create a Qwik app 🐇 v0.11.0
✔ Where would you like to create your new project? … ./qwik-app
? Select a starter › (use ↓↑ arrows, hit enter)
❯ Basic
Recommended for your first Qwik app (comes with Qwik City)
Documentation Site
Library
インストールが完了したら以下コマンドで開発サーバーを起動します。
cd qwik-app
npm run dev
http://localhost:5173 を訪れてみましょう。次のように表示されているはずです!
Static Site Generation (SSG) インテグレーションの追加
qwik add
コマンドにより、さまざまなインテグレーションを追加できます。現在追加可能なインテグレーションは次のとおりです。
今回は Static Site Generation (SSG)インテグレーションを追加します。Sstatic Site Generation は日本語では静的サイトジェネレーションと呼ばれ、ビルド時に静的な HTML を生成する仕組みです。Server Side Rendering(SSR)と異なり、ユーザーがサイトに訪れるたびに HTML を構築する必要がなく、また「hydration」も必要がないのでパフォーマンス上メリットがあります。一方でログインしているユーザーの情報に応じてコンテンツを出し分けるなどは行うことができません。そのため、すべてのユーザーに同じ内容を提供するブログやドキュメントサイトに適しています。
以下のコマンドで SSG インテグレーションを追加しましょう。
npm run qwik add static-node
コマンドを実行すると entry.static.tsx
ファイルが追加されています。このファイルがビルド時のエントリーポイントとなります。
import { qwikCityGenerate } from '@builder.io/qwik-city/static/node';
import render from './entry.ssr';
import { fileURLToPath } from 'url';
import { join } from 'path';
// Execute Qwik City Static Site Generator
qwikCityGenerate(render, {
origin: 'https://qwik.builder.io',
outDir: join(fileURLToPath(import.meta.url), '..', '..', 'dist'),
});
SSG を実行する qwikCityGenerate
関数は @builder.io/qwik-city/static/node
モジュールからインポートされています。このことは将来 Node.js に限らず Deno や Bun などをビルド時のランタイムとして使用できるようになることを示しています。
また package.json
に build.static
コマンドが実行されており、このコマンドにより SSG のビルドを実行できます。
ディレクトリ構造
まずはじめに Qwik City のディレクトリ後続を確認しておきましょう。
src/routes
Qwik City は Next.js のようにファイルシステムベースのルーティングを採用しています。ファイルシステムベースのルーティングとは、ある特定のディレクトリの構造がそのままパスの構造となることを指します。
Qwik Coty では src/routes
配下のディレクトリが対象となります。例えば src/routes/about/index.tsx
という場所にファイルを作成すると、自動的に /about
というパスを担当することになります。実際に src/routes/about/index.tsx
にファイルを作成して試してみましょう。
import { component$ } from "@builder.io/qwik";
import type { DocumentHead } from "@builder.io/qwik-city";
export const head: DocumentHead = {
title: "About ",
meta: [
{
name: "description",
content: "This is about page.",
},
],
};
export default component$(() => {
return (
<div>
<h1>This is about page.</h1>
</div>
);
});
静的な <head>
を生成する場合には head
オブジェクトを export します。
それでは http://localhost:5173/about にアクセスしてみましょう。src/pages/about/index.tsx
に作成した内容が表示されています。
! 原因は不明ですが、新しいページを作成した後該当のパスを訪れても 404 エラーとなり何も表示されません。ターミナルのログには
QWIK WARN A unsupported value was passed to the JSX, skipping render. Value: Symbol(skip render)
と表示されているかと思います。このような場合には、一度ジョブを終了してから再度npm run dev
を実行すると表示されるようです。
Qwik City ではその他のファイルシステムベースのルーティングとは異なり、ファイル名は必ず index.[filetype]
とする必要があります。例えば、src/routes/contact.tsx
というファイルを作成しても、/contract
というパスは登録されません。必ず src/routes/contract/index.tsx
のような形式にする必要があります。
この方式を採用する利点として、routes
フォルダ配下にページに関連するコンポーネントなどを配置できる点があげられます。Next.js の方式の場合、pages
ディレクトリ配下に作成したファイルはすべて自動的にルーティングが登録されてしまうため、ページ関連以外のファイルを配置できません。Qwik City の形式の場合、index.[filetype]
以外の形式のファイルは無視されるため、気にせず配置できます。
Qwik City は .mdx
形式のファイルもサポートしています。MDX はマークダウンと jsx でページを作成する機能です。src/routes/contact/index.mdx
ファイルを作成して Contarct ページを MDX で作ってみましょう。
---
title: Contact
---
import Form from "./Form";
# Contact
For more information, _please contact_:
<Form />
一般的なマークダウン記法とともに jsx を書くことができるので、<Form />
コンポーネントをインポートして問い合わせフォームを表示しています。<Form />
コンポーネントを作成しましょう。前述のとおり、Qwik City では src/routes
配下にページ以外のファイルを配置できるので、src/pages/contact/Form.tsx
に作成します。
import { component$, useStylesScoped$ } from "@builder.io/qwik";
import styles from "./form.css?inline";
export default component$(() => {
useStylesScoped$(styles);
return (
<form>
<label for="name">Name</label>
<input id="name" name="name" type="text" />
<label for="email">Email</label>
<input id="email" name="email" type="email" />
<label for="message">Message</label>
<textarea id="message" name="message" />
<button type="submit">Send</button>
</form>
);
});
中身はよくあるフォームです。useStylesScoped$
フックを使用することで、特定のコンポーネント内にのみスタイルが適用される scoped styles としてスタイルを適用できます。
src/pages/contract/form.css
ファイルも作成しておきましょう。
form {
display: flex;
flex-direction: column;
width: 100%;
max-width: 500px;
margin: 0 auto;
}
input, textarea {
margin: 0 0 1rem;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
padding: 0.5rem 1rem;
border: 1px solid #ccc;
border-radius: 4px;
background: #fff;
cursor: pointer;
}
これで http://localhost:5173/contract を訪れると、Contact ページが作成されていることがわかります。
layouts.tsx
src/ruotes
配下に存在する layouts.tsx
ファイルも特別な意味を持つファイルです。laytouts.tsx
はページ間で再利用されるナビゲーションバーやフッターをレイアウトとして提供します。デフォルトの layout.tsx
は次のようになっています。
import { component$, Slot } from '@builder.io/qwik';
import Header from '../components/header/header';
export default component$(() => {
return (
<>
<main>
<Header />
<section>
<Slot />
</section>
</main>
<footer>
<a href="https://www.builder.io/" target="_blank">
Made with ♡ by Builder.io
</a>
</footer>
</>
);
});
<Slot />
コンポーネントを配置した箇所にそれぞれのルートのページが描画されます。せっかくなので、ヘッダーとフッターを少し編集しておきましょう。
import { component$, useStylesScoped$ } from "@builder.io/qwik";
import { QwikLogo } from "../icons/qwik";
import { Link } from "@builder.io/qwik-city";
import styles from "./header.css?inline";
type NavItemProps = {
href: string;
label: string;
};
export const NavItem = ({ href, label }: NavItemProps) => {
return (
<li>
<Link href={href}>{label}</Link>
</li>
);
};
export default component$(() => {
useStylesScoped$(styles);
return (
<header>
<div class="logo">
<a href="https://qwik.builder.io/" target="_blank">
<QwikLogo />
</a>
</div>
<ul>
<NavItem href="/" label="Home" />
<NavItem href="/about" label="About" />
<NavItem href="/contact" label="Contact" />
</ul>
</header>
);
});
内部のリンクは @builder.io/qwik-city
からインポートして <Link />
コンポーネントを使用します。1 つ注意しなければいけない点として、コンポーネントはすべて export
する必要があります。<NavItem />
コンポーネントはこのヘッダーコンポーネント内部でのみ使用するのにも関わらず export
しているのはそのためです。これは Qwik の遅延ロードのためのオプティマイザの制約によるものです。詳しくは以下を参照してください。
レイアウトのネスト
レイアウトはネストすることも可能です。routes
ディレクトリ内の階層に対して layout.tsx
を作成することでさらにレイアウトを適用できます。
例えば src/routes/about/layout.tsx
を作成した場合 /about
ページには次の順番でレイアウトが適用されます。
src/routes/layout.tsx
src/routes/about/layout.tsx
実際に src/routes/about/layout.tsx
ファイルを作成して試してみましょう。/about
配下のすべてのページにはトップに画像を表示することにしてみましょう。
import { Slot, component$ } from "@builder.io/qwik";
export default component$(() => {
return (
<>
<img
src="https://i.picsum.photos/id/1006/3000/2000.jpg?hmac=x83pQQ7LW1UTo8HxBcIWuRIVeN_uCg0cG6keXvNvM8g"
width={600}
height={400}
alt="Dog and woman standing next to each other looking at the landscape from a cliff"
/>
<Slot />
</>
);
});
http://localhost:5173/about にアクセスしてみると、たしかに作成したレイアウトが使用されています。
root.tsx
root.tsx
ファイルは Next.js の _document.tsx
や App.tsx
のようにアプリケーション全体の設定を行えます。例えばグローバル CSS を読み込んだり <head>
の設定を行うために利用されます。
import { component$ } from '@builder.io/qwik';
import { QwikCity, RouterOutlet, ServiceWorkerRegister } from '@builder.io/qwik-city';
import { RouterHead } from './components/router-head/router-head';
import './global.css';
export default component$(() => {
/**
* The root of a QwikCity site always start with the <QwikCity> component,
* immediately followed by the document's <head> and <body>.
*
* Dont remove the `<head>` and `<body>` elements.
*/
return (
<QwikCity>
<head>
<meta charSet="utf-8" />
<RouterHead />
</head>
<body lang="en">
<RouterOutlet />
<ServiceWorkerRegister />
</body>
</QwikCity>
);
});
src/components
コンポーネントを配置するためのディレクトリとして推奨されています。ここに配置したファイルが特別な意味を持つことはありません。
public
画像など、静的なファイルを配置するディレクトリです。Vite public directory も参考にしてください。
microCMS のセットアップ
ブログのコンテンツを管理するために microCMS と呼ばれるヘッドレス CMS(Content Management System)を利用します。ヘッドレス CMS とは、コンテンツを管理するバックエンド側のみを提供する仕組み。従来の WordPress のような通常の CMS では、コンテンツを管理する仕組みとコンテンツを表示する仕組みのどちらも提供していましたが、ヘッドレス CMS ではコンテンツを表示する仕組みを自由に管理できるのが特徴です。
一般にヘッドレス CMS では管理画面からコンテンツを作成した後、API を用いてコンテンツ情報を取得して表示することになります。
はじめに microCMS のサインイン画面からアカウントを登録しておきましょう。
アカウントの登録が完了したら、続いてサービスを作成します。エンドポイント名は後から使用するで控えておくと良いでしょう。 サービス名は任意で構いません。
API のスキーマを作成します。ヘッドレス CMS ではコンテンツの形式を自由に決定できるのが特徴ですが、ここでは簡単に進めるためにあらかじめ用意されている「ブログ」テンプレートを使用しましょう。
作成ボタンからいくつか記事を作成しておきましょう。
サイドメニュの「1 個の API キー」から API キーを取得して控えておきます。この API キーは公開されてはいけません。
ここまでの作業で microCMS 側での作業は完了です。Qwik City のコードに戻り、.env
ファイルを作成して控えておいた API キーとエンドポイント名を記述します。
VITE_API_KEY=XXXXXX
VITE_SERVICE_DOMAIN=XXXXXX
.env
ファイルに記述した内容は import.meta.env
から取得できます。
API クライアントの作成
microCMS の API から記事を取得する API クライアントを作成します。API の仕様は以下のドキュメントから参照できます。
src/lib/client.ts
にファイルを作成します。
import { createClient } from "microcms-js-sdk";
export type Response<T> = {
contents: T[];
totalCount: number;
offset: number;
limit: number;
};
export type Post = {
id: string;
title: string;
content: string;
publishedAt: string;
revisedAt: string;
updatedAt: string;
};
const baseUrl = `https://${
import.meta.env.VITE_SERVICE_DOMAIN
}.microcms.io/api/v1/blogs`;
const requestInit: RequestInit = {
headers: {
"X-API-KEY": import.meta.env.VITE_API_KEY,
},
};
export const getPostList = async (): Promise<ContentsResponse<Post>> => {
const response = await fetch(baseUrl, requestInit);
const data = await response.json();
return data;
};
export const getPost = async (id: string): Promise<Post> => {
const response = await fetch(`${baseUrl}/${id}`, requestInit);
const data = await response.json();
return data;
};
ContentsResponse
は microCMS コンテンツ API のレスポンスの型で、Post
がコンテンツの形式です。
API のエンドポイントは https://{サービスドメイン名}.microcms.io/api/v1/{エンドポイント名}
の形式となっております。このエンドポイントを baseUrl
として設定しておきます。
リクエストヘッダーには X-MICROCMS-API-KEY: {API キー}
を認証情報として含める必要があります。共通のヘッダーを requestInit
そして設定します。
getPostList
と getPost
はそれぞれ記事の一覧の取得、特定のコンテンツの取得を行う関数です。特定のコンテンツを取得するためにはコンテンツの ID を使用するので、getPost
関数の引数で id
を受け取るようにしています。
記事一覧の取得
microCMS のセットアップが完了したので、早速トップ画面で記事の一覧を取得してみましょう。Qwik City においてコンポーネントが描画される前にサーバーサイドでデータを取得には以下の HTTP メソッドに対応したリクエストハンドラ関数を実装します。
onGet
onPost
onPut
onRequest
GET
メソッドでルートが呼び出された場合には onGet
関数が、Post
メソッドでルートが呼び出された場合には onPost
関数がそれぞれ呼ばれます。onRequest
関数はすべてのリクエストメソッドに対応しており、フォールバックとして使用できます。
またこれらのリクエストハンドラ関数はページコンポーネントやレイアウトコンポーネントなど pages
ディレクトリ配下のみで利用できます。
記事一覧ページにおいては、以下のように onGet
関数を実装して export します。
import { RequestHandler } from "@builder.io/qwik-city";
import { getPostList, Post } from "~/lib/client";
export const onGet: RequestHandler<Post[]> = async () => {
const data = await getPostList();
return data.contents;
};
onGet
関数で取得したデータをコンポーネントから利用するためには useEndpoint
フックを使います。useEndpoint
フックは ResourceReturn 型のオブジェクトを返します。ResourceReturn
型は Promise ライクで Qwik によりシリアライズ可能なオブジェクトです。resource.state
はリソースの状態を表しており、以下のいずれかとなります。
pending
- データを取得中であるresolved
- データを利用可能であるrejected
- エラーまたはタイムアウトのためデータを利用できない
ResourceReturn
型のオブジェクトは onPending
,onRejected
,OnResolved
はそれぞれ resource.state
の pending
,resolved
,rejected
に対応しています。
import { component$, Resource } from "@builder.io/qwik";
import { RequestHandler, useEndpoint } from "@builder.io/qwik-city";
export default component$(() => {
const resource = useEndpoint<Post[]>();
return (
<div>
<h1>Post List</h1>
<Resource
value={resource}
onPending={() => <div>Loading...</div>}
onRejected={(error) => <div>Error: {error.message}</div>}
onResolved={(posts) => (
<ul>
{posts.map((post) => (
<PostItem key={post.id} post={post} />
))}
</ul>
)}
/>
</div>
);
});
一覧ページの全体像は以下のとおりです。
import { component$, Resource } from "@builder.io/qwik";
import {
DocumentHead,
RequestHandler,
useEndpoint,
} from "@builder.io/qwik-city";
import { Link } from "@builder.io/qwik-city";
import { client, ContentsResponse, Post } from "~/lib/client";
export const onGet: RequestHandler<Post[]> = async () => {
const data = await client.get<ContentsResponse<Post>>({ endpoint: "blogs" });
return data.contents;
};
export const head: DocumentHead = {
title: "Qwik Blog",
};
export const PostItem = component$((props: { post: Post }) => {
const { post } = props;
return (
<li>
<Link href={`/posts/${post.id}`}>{post.title}</Link>
</li>
);
});
export default component$(() => {
const value = useEndpoint<Post[]>();
return (
<div>
<h1>Post List</h1>
<Resource
value={value}
onPending={() => <div>Loading...</div>}
onRejected={(error) => <div>Error: {error.message}</div>}
onResolved={(posts) => (
<ul>
{posts.map((post) => (
<PostItem key={post.id} post={post} />
))}
</ul>
)}
/>
</div>
);
});
実際に記事の一覧が表示されていることを確認しましょう。
記事詳細画面
続いて、ブログの詳細を表示する画面を作成しましょう。src/pages/posts/[id]/index.tsx
ファイルを作成します。[id]
のようにディレクトリ名を []
で囲った場合パスパラメータとして使用できます。ルート名をパスパラメータとした場合、以下のように動的にルートにマッチします。
- /posts/i62f_oybcsx
- /posts/y-e7ewlx7
- /posts/10pj070vden
記事の取得
microCMS の API では content_id
を指定して特定のコンテンツを取得できるので、ブログ詳細画面ではこのパスパラメータを利用してコンテンツを取得します。
パスパラメータは onGet
関数の引数の params
から取得できます。
export const onGet: RequestHandler<Post> = async ({ params }) => {
const data = await getPost(params.id);
return data;
};
SSG におおける動的ルーティング
SSG においてはパスパラメータを用いて動的にルートにマッチングさせる場合にも、ビルド時にあらかじめどのようなパラメータ id
でアクセスが行われるか把握して各ページを生成しておく必要があります。これを実現するために、onStaticGenerate
関数を実装します。
onStaticGenerate
関数内では getPostList
関数ですべての記事を取得し、id
の配列として返却します。ここで返却した id
のリストが事前生成されるページのパスとなります。
export const onStaticGenerate: StaticGenerateHandler = async () => {
const data = await getPostList();
const ids = data.contents.map((post) => post.id);
return {
params: ids.map((id) => {
return { id };
}),
};
};
動的な Head の設定
<head>
に設定する値があらかじめ決まっているならば前述のとおり head
オブジェクトを export すればよいのですが、ここでは API から取得した値を動的に設定する必要があります。そのような場合には、head
を関数として利用します。
export const head: DocumentHead<Post> = ({ data }) => {
return {
title: data.title,
meta: [
{
name: "description",
content: data.content.slice(0, 100) + "...",
},
],
};
};
関数に引数の data
には onGet
の返り値が渡されるので、API から取得した値を使って <head>
を設定できます。
コンポーネントの実装
最後に取得した記事データをもとにコンポーネントを実装しましょう。記事一覧の場合と同様に useEndPoint
フックから onGet
関数で取得したデータを ResourceReturn<T>
として取得できます。
記事の内容は post.content
に HTML 形式で入っているため dangerouslySetInnerHTML
で HTML として描画できます。
export default component$(() => {
const resource = useEndpoint<Post>();
return (
<Resource
value={resource}
onPending={() => <div>Loading...</div>}
onRejected={(error) => <div>Error: {error.message}</div>}
onResolved={(post) => (
<article>
<h1>{post.title}</h1>
<p>
<time>
{new Date(post.publishedAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
</p>
<div dangerouslySetInnerHTML={post.content} />
</article>
)}
/>
);
});
最終的な内容は以下のようになります。
import { component$, Resource } from "@builder.io/qwik";
import {
DocumentHead,
RequestHandler,
StaticGenerateHandler,
useEndpoint,
} from "@builder.io/qwik-city";
import { getPost, getPostList, Post } from "~/lib/client";
export const onGet: RequestHandler<Post> = async ({ params }) => {
const data = await getPost(params.id);
return data;
};
export const onStaticGenerate: StaticGenerateHandler = async () => {
const data = await getPostList();
const ids = data.contents.map((post) => post.id);
return {
params: ids.map((id) => {
return { id };
}),
};
};
export const head: DocumentHead<Post> = ({ data }) => {
return {
title: data.title,
meta: [
{
name: "description",
content: data.content.slice(0, 100) + "...",
},
],
};
};
export default component$(() => {
const resource = useEndpoint<Post>();
return (
<Resource
value={resource}
onPending={() => <div>Loading...</div>}
onRejected={(error) => <div>Error: {error.message}</div>}
onResolved={(post) => (
<article>
<h1>{post.title}</h1>
<p>
<time>
{new Date(post.publishedAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
</p>
<div dangerouslySetInnerHTML={post.content} />
</article>
)}
/>
);
});
次のように、作成した記事の内容が表示されていることでしょう。
デプロイ
最後に作成したアプリケーションをデプロイしましょう。デプロイ先には Vercel を選択しました。サインインしてアカウントを作成しておきます。
作成したアプリケーションはあらかじめ GitHub レポジトリに公開しておきます。新しいプロジェクトを作成する際に「Continue with GitHub」を選択して、公開したレポジトリを選択します。
「Build and Output Settings」からビルドの設定します。
- BUILD COMMAND -
npm run build.client && npm run build.static && npm run ssg
- OUTPUT DIRECTORY -
dist
Environment Variables には .env
に設定した値を設定します。
設定が完了したら「Deploy」ボタンでデプロイを実行します。
以下の画面が表示されていればデプロイは成功しています。
デプロイしたアプリケーションを確認してみましょう!