Next.jsのチュートリアルをTypeScriptでなぞってみます。
今回の記事ではデプロイは対象外としてます。
公式はこちらです。
https://nextjs.org/learn/basics/create-nextjs-app
Create a Next.js App
プロジェクトを作成します。
すべてデフォルトを選択しました。
npx create-next-app nextjs-blog
✔ Would you like to use TypeScript with this project? … No / Yes
✔ Would you like to use ESLint with this project? … No / Yes
✔ Would you like to use `src/` directory with this project? … No / Yes
✔ Would you like to use experimental `app/` directory with this project? … No / Yes
✔ What import alias would you like configured? … @/*
プロジェクトを作成できたら以下のコマンドで開発サーバーを立てることができます。
npm run dev
Navigate Between Pages
Pages in Next.js
Next.jsにおけるpagesディレクトリの説明です。
pages/index.js
は/
に、
pages/posts/first-post.js
は/posts/first-post
に該当します。
Create a New Page
pagesディレクトリ直下にpostsディレクトリを作成して、その直下にfirst-posts.tsxファイルを作成します。
export default function FirstPost() {
return (
<h1>First Post</h1>
)
}
posts/first-post
にアクセスすると画像のような表示になります。
Link Component
ページ間の画面線に着目します。
クライアントサイドのナビゲーションを可能にします。
トップページからFirstPost
へ遷移できるようにしましょう。
import Link from "next/link"
export default function Home() {
return (
<h1 className="title">
Read <Link href="/posts/first-post">this page!</Link>
</h1>
)
}
FirstPost
からトップページへ戻れるようにします。
import Link from 'next/link';
export default function FirstPost() {
return (
<>
<h1>First Post</h1>
<h2>
<Link href="/">Back to home</Link>
</h2>
</>
);
}
Assets, Metadata, and CSS
Adding Head to first-post.js
FirstPost
ページのタイトル属性を変更してみましょう。
Head
コンポーネントを使うことで実現できます。
import Link from 'next/link';
import Head from 'next/head';
export default function FirstPost() {
return (
<>
<Head>
<title>First Post</title>
</Head>
<h1>First Post</h1>
<h2>
<Link href="/">Back to home</Link>
</h2>
</>
);
}
Third-Party JavaScript
プロジェクトのサードパーティ製のJavaScriptを追加します。
Next.jsにはScript
というコンポーネントをが用意されています。
first-post.tsx
に使ってみましょう。
import Link from 'next/link';
import Head from 'next/head';
import Script from 'next/script';
export default function FirstPost() {
return (
<>
<Head>
<title>First Post</title>
</Head>
<Script
src='https://connect.facebook.net/en_US/sdk.js'
strategy='lazyOnload'
onLoad={() => {
console.log('loaded');
}}
/>
<h1>First Post</h1>
<h2>
<Link href="/">Back to home</Link>
</h2>
</>
);
}
Script
にはさまざまなオプションが用意されています。
strategy
にlazyonload
を指定するとブラウザがアイドル状態の時にJavaScriptを読み込んでくれます。
onLoad
に処理を記載することで、読み込んだ後に実行したい処理を指定できます。
サードパーティJavaScriptの導入はブログ作成とは関係ないので、確認したら削除してください。
Layout Component
複数の共通のパーツを使いまわしたい時があると思います。
layout.tsx
というファイルを作成してみましょう。
import { ReactNode } from 'react'
interface Props {
children: ReactNode
}
export default function Layout({ children }: Props) {
return (
<div>
{children}
</div>
)
}
作成したファイルをfirst-post.tsx
で使います。
import Link from 'next/link';
import Head from 'next/head';
import Layout from '@/components/layout';
export default function FirstPost() {
return (
<Layout>
<Head>
<title>First Post</title>
</Head>
<h1>First Post</h1>
<h2>
<Link href="/">Back to home</Link>
</h2>
</Layout>
);
}
webページのヘッダーやフッターは同じパーツを使うことで統一感が出てわかりやすいと思います。
Adding CSS
先ほど作成したlayout.tsx
にスタイルを適用しましょう。
layout.module.css
を作成します。
.container {
max-width: 36rem;
padding: 0 1rem;
margin: 3rem auto 6rem;
}
cssをlayout.tsx
に適用します。
import { ReactNode } from 'react'
import styles from './layout.module.css'
interface Props {
children: ReactNode
}
export default function Layout({ children }: Props) {
return (
<div className={styles.container}>
{children}
</div>
)
}
Global Styles
global.css
を編集することでサイト全体にスタイルを適用できます。
リセットCSSなどを読み込むのが一般的かと思います。
global.css
を以下のように編集してみましょう。
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu,
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
line-height: 1.6;
font-size: 18px;
}
* {
box-sizing: border-box;
}
a {
color: #0070f3;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
img {
max-width: 100%;
display: block;
}
Polishing Layout
CSSモジュールを使ってレイアウトを更新していきます。
.container {
max-width: 36rem;
padding: 0 1rem;
margin: 3rem auto 6rem;
}
.header {
display: flex;
flex-direction: column;
align-items: center;
}
.backToHome {
margin: 3rem 0 0;
}
.heading2Xl {
font-size: 2.5rem;
line-height: 1.2;
font-weight: 800;
letter-spacing: -0.05rem;
margin: 1rem 0;
}
.headingXl {
font-size: 2rem;
line-height: 1.3;
font-weight: 800;
letter-spacing: -0.05rem;
margin: 1rem 0;
}
.headingLg {
font-size: 1.5rem;
line-height: 1.4;
margin: 1rem 0;
}
.headingMd {
font-size: 1.2rem;
line-height: 1.5;
}
.borderCircle {
border-radius: 9999px;
}
.colorInherit {
color: inherit;
}
.padding1px {
padding-top: 1px;
}
.list {
list-style: none;
padding: 0;
margin: 0;
}
.listItem {
margin: 0 0 1.25rem;
}
.lightText {
color: #666;
}
import Image from 'next/image';
import styles from './layout.module.css';
import utilStyles from '../styles/utils.module.css';
import Link from 'next/link';
import { ReactNode } from 'react';
const name = 'Your Name';
export const siteTitle = 'Next.js Sample Website';
interface Props {
children: ReactNode
home: boolean
}
export default function Layout({ children, home }: Props) {
return (
<div className={styles.container}>
<Head>
<link rel="icon" href="/favicon.ico" />
<meta
name="description"
content="Learn how to build a personal website using Next.js"
/>
<meta
property="og:image"
content={`https://og-image.vercel.app/${encodeURI(
siteTitle,
)}.png?theme=light&md=0&fontSize=75px&images=https%3A%2F%2Fassets.vercel.com%2Fimage%2Fupload%2Ffront%2Fassets%2Fdesign%2Fnextjs-black-logo.svg`}
/>
<meta name="og:title" content={siteTitle} />
<meta name="twitter:card" content="summary_large_image" />
</Head>
<header className={styles.header}>
{home ? (
<>
<Image
priority
src="/images/profile.jpg"
className={utilStyles.borderCircle}
height={144}
width={144}
alt=""
/>
<h1 className={utilStyles.heading2Xl}>{name}</h1>
</>
) : (
<>
<Link href="/">
<Image
priority
src="/images/profile.jpg"
className={utilStyles.borderCircle}
height={108}
width={108}
alt=""
/>
</Link>
<h2 className={utilStyles.headingLg}>
<Link href="/" className={utilStyles.colorInherit}>
{name}
</Link>
</h2>
</>
)}
</header>
<main>{children}</main>
{!home && (
<div className={styles.backToHome}>
<Link href="/">← Back to home</Link>
</div>
)}
</div>
);
}
import Head from 'next/head';
import Layout, { siteTitle } from '../components/layout';
import utilStyles from '../styles/utils.module.css';
export default function Home() {
return (
<Layout home>
<Head>
<title>{siteTitle}</title>
</Head>
<section className={utilStyles.headingMd}>
<p>Hello, World</p>
<p>
(This is a sample website - you’ll be building a site like this on{' '}
<a href="https://nextjs.org/learn">our Next.js tutorial</a>.)
</p>
</section>
</Layout>
);
}
見た目が整えられてきました。
Pre-rendering and Data Fetching
今まではファイル分割の仕方やスタイルの当て方などUIに関わること主にみてきました。
ここからはロジックの箇所を詳しく学習します。
Pre-rendering
Next.jsの特徴の一つがプリレンダリングです。
プリレンダリングはサーバー側でHTMLを作成することで、ファーストビューでUIが見えるというメリットがあります。
プリレンダリングにはSSGとSSRという二つの種類があります。
SSGはビルド時のみHTMLを作成します。
SSRはリクエストのたびにHTMLを作成します。
SSGの方が高速なため、主にSSGを使用して要件に合わせてSSRを使うのが良さそうです。
Creating the markdown files
今回作成するブログではプロジェクトにマークダウンファイルを作成して、そのファイルをブログの記事とします。
またSSGを使ってブログシステムを構築します。
まずマークダウンファイルを追加しましょう。
---
title: 'Two Forms of Pre-rendering'
date: '2020-01-01'
---
Next.js has two forms of pre-rendering: **Static Generation** and **Server-side Rendering**. The difference is in **when** it generates the HTML for a page.
- **Static Generation** is the pre-rendering method that generates the HTML at **build time**. The pre-rendered HTML is then _reused_ on each request.
- **Server-side Rendering** is the pre-rendering method that generates the HTML on **each request**.
Importantly, Next.js lets you **choose** which pre-rendering form to use for each page. You can create a "hybrid" Next.js app by using Static Generation for most pages and using Server-side Rendering for others.
---
title: 'When to Use Static Generation v.s. Server-side Rendering'
date: '2020-01-02'
---
We recommend using **Static Generation** (with and without data) whenever possible because your page can be built once and served by CDN, which makes it much faster than having a server render the page on every request.
You can use Static Generation for many types of pages, including:
- Marketing pages
- Blog posts
- E-commerce product listings
- Help and documentation
You should ask yourself: "Can I pre-render this page **ahead** of a user's request?" If the answer is yes, then you should choose Static Generation.
Creating the utility function to read the file system
マークダウンファイルを追加できたら、それらを表現するモデルを作成します。
export class Post {
id: string;
date: string;
title: string;
content: string;
constructor(id: string, date: string, title: string, content: string) {
this.id = id;
this.date = date;
this.title = title;
this.content = content;
}
}
ファイルシステムから読み込んでPost
の配列を返す関数を作成します。
import fs from 'fs';
import path from 'path';
import matter from 'gray-matter';
import { Post } from '@/types/post';
const postsDirectory = path.join(process.cwd(), 'posts');
export function getSortedPostsData(): Post[] {
const fileNames = fs.readdirSync(postsDirectory);
const allPostsData = fileNames.map((fileName) => {
const id = fileName.replace(/\.md$/, '');
const fullPath = path.join(postsDirectory, fileName);
const fileContents = fs.readFileSync(fullPath, 'utf8');
const matterResult = matter(fileContents);
return new Post(
id,
matterResult.data.date,
matterResult.data.title,
matterResult.content,
)
});
const sorted = allPostsData.sort((a, b) => {
if (a.date < b.date) {
return 1;
} else {
return -1;
}
})
return JSON.parse(JSON.stringify(sorted));
}
Using Static Generation (getStaticProps())
getStaticProps()
を使います。
HTMLのビルドに必要な外部データを取得することができます。
index.tsx
で使ってみましょう。
import Head from 'next/head';
import Layout, { siteTitle } from '../components/layout';
import utilStyles from '../styles/utils.module.css';
import { getSortedPostsData } from '@/lib/posts';
import { Post } from '@/types/post';
interface Props {
allPostsData: Post[]
}
export async function getStaticProps(): Promise<{ props: Props }> {
const allPostsData = getSortedPostsData();
return {
props: {
allPostsData
}
}
}
export default function Home({ allPostsData }: Props) {
return (
<Layout home>
<Head>
<title>{siteTitle}</title>
</Head>
<section className={utilStyles.headingMd}>
<p>Hello, World</p>
<p>
(This is a sample website - you’ll be building a site like this on{' '}
<a href="https://nextjs.org/learn">our Next.js tutorial</a>.)
</p>
</section>
<section className={`${utilStyles.headingMd} ${utilStyles.padding1px}`}>
<h2 className={utilStyles.headingLg}>Blog</h2>
<ul className={utilStyles.list}>
{allPostsData.map(({ id, date, title }) => (
<li className={utilStyles.listItem} key={id}>
{title}
<br />
{id}
<br />
{date}
</li>
))}
</ul>
</section>
</Layout>
);
}
取得したデータはprops
としてHome
コンポーネントに渡すことができます。
Dynamic Routes
投稿の詳細画面へ遷移できるようにします。
詳細画面のパスは/posts/ブログ名
です。
pages/posts/[id].tsx
を作成することで該当の画面へ遷移できます。
このような動的ルーティングを実現するためにはgetStaticPaths
とgetStaticProps
を実装します。
Implement getStaticPaths
ここではファイル名の一覧を返してあげます。
まずlib/posts.ts
にファイルの一覧を返す関数を追加しましょう。
export function getAllPostIds() {
const fileNames = fs.readdirSync(postsDirectory);
return fileNames.map((fileName) => {
return {
params: {
id: fileName.replace(/\.md$/, ''),
},
}
})
}
ここで実装した関数を[id].tsx
のgetStaticPaths
で呼び出します。
export async function getStaticPaths() {
const paths = getAllPostIds();
return {
paths,
fallback: false
}
}
Implement getStaticProps
次に詳細画面で表示したいデータを取得します。
ファイル名をキーにブログの詳細を取得します。
lib/posts.ts
にメソッドを追加しましょう。
export function getPostData(id: string): Post {
const fullPath = path.join(postsDirectory, `${id}.md`);
const fileContents = fs.readFileSync(fullPath, 'utf8');
const matterResult = matter(fileContents);
const post = new Post(
id,
matterResult.data.date,
matterResult.data.title,
matterResult.content,
)
return JSON.parse(JSON.stringify(post))
}
getPostData
を[id].tsx
のgetStaticProps
で実行します。
import Layout from "@/components/layout";
import { getAllPostIds, getPostData } from "@/lib/posts";
import { Post } from "@/types/post";
export async function getStaticPaths() {
const paths = getAllPostIds();
return {
paths,
fallback: false
}
}
export async function getStaticProps({ params }: { params: { id: string } }) {
const postData = await getPostData(params.id);
return {
props: {
postData
}
}
}
interface Props {
postData: Post
}
export default function PostDetail({ postData }: Props) {
return (
<Layout home={false}>
{postData.title}
<br />
{postData.id}
<br />
{postData.date}
</Layout>
);
}
取得した内容を画面に表示すると画像のようになります。
Render Markdown
ブログの本文を画面に表示しましょう。
マークダウンをHTMLへ変換するライブラリとしてremark
とremark-html
をインストールします。
npm install remark remark-html
Postの詳細情報を取得するときにHTMLの文字列を取得するように修正します。
HTML文字列をそのまま画面に表示します。
まずPost詳細情報取得の箇所を修正します。
export async function getPostData(id: string): Promise<Post> {
const fullPath = path.join(postsDirectory, `${id}.md`);
const fileContents = fs.readFileSync(fullPath, 'utf8');
const matterResult = matter(fileContents);
const processedContent = await remark()
.use(html)
.process(matterResult.content)
const contentHtml = processedContent.toString()
const post = new Post(
id,
matterResult.data.date,
matterResult.data.title,
contentHtml,
)
return JSON.parse(JSON.stringify(post))
}
取得してきた文字列を詳細画面で表示します。
dangerouslySetInnerHTML
に文字列を指定することで実現できます。
export default function PostDetail({ postData }: Props) {
return (
<Layout home={false}>
{postData.title}
<br />
{postData.id}
<br />
{postData.date}
<br />
<div dangerouslySetInnerHTML={{ __html: postData.content }} />
</Layout>
);
}
Adding title to the Post Page
詳細画面のタイトルを設定しましょう。
pages/posts/[id].tsx
にもHead
コンポーネントを追加します。
export default function PostDetail({ postData }: Props) {
return (
<Layout home={false}>
<Head>
<title>{postData.title}</title>
</Head>
<article>
<h1 className={utilStyles.headingXl}>{postData.title}</h1>
<div className={utilStyles.lightText}>
<Date dateString={postData.date} />
</div>
<div dangerouslySetInnerHTML={{ __html: postData.content }} />
</article>
</Layout>
);
}
Formatting the Date
日付の表示を整えます。
January 2, 2020
という表示になるようにします。
date-fns
というライブラリをインストールします。
npm install date-fns
date-fns
を使って日付を表示するコンポーネントを実装します。
import { format, parseISO } from "date-fns"
interface Props {
dateString: string
}
export default function Date({ dateString }: Props) {
const date = parseISO(dateString)
return <time dateTime={dateString}>{format(date, 'LLLL d, yyyy')}</time>
}
Date
コンポーネントをpages/posts/[id].tsx
で使います。
export default function PostDetail({ postData }: Props) {
return (
<Layout home={false}>
<Head>
<title>{postData.title}</title>
</Head>
<article>
<h1 className={utilStyles.headingXl}>{postData.title}</h1>
<div className={utilStyles.lightText}>
<Date dateString={postData.date} />
</div>
<div dangerouslySetInnerHTML={{ __html: postData.content }} />
</article>
</Layout>
);
}
表示は画像のような感じです。
Polishing the Index Page
ホームページから各投稿の詳細へ遷移できるようにします。
既存のli
要素をにLink
コンポーネントを使って対応します。
export default function Home({ allPostsData }: Props) {
return (
<Layout home>
<Head>
<title>{siteTitle}</title>
</Head>
<section className={utilStyles.headingMd}>
<p>Hello, World</p>
<p>
(This is a sample website - you’ll be building a site like this on{' '}
<a href="https://nextjs.org/learn">our Next.js tutorial</a>.)
</p>
</section>
<section className={`${utilStyles.headingMd} ${utilStyles.padding1px}`}>
<h2 className={utilStyles.headingLg}>Blog</h2>
<ul className={utilStyles.list}>
{allPostsData.map(({ id, date, title }) => (
<li className={utilStyles.listItem} key={id}>
<Link href={`/posts/${id}`}>{title}</Link>
<br />
<small className={utilStyles.lightText}>
<Date dateString={date} />
</small>
</li>
))}
</ul>
</section>
</Layout>
);
}
以上でブログアプリは完成です。
ソースコードはこちらです。
https://github.com/h-taro/nextjs-blog