Chapter 03

ちょっと動的なページにしてみよう

oubakiou
oubakiou
2021.06.28に更新

動的なパスを持ったページを作ってみよう

pagesの下に新しくstatusesというディレクトリを作り、その中に[id].tsxという名前で下記のファイルを作ります。

src/pages/statuses/[id].tsx
import React from 'react'
import App from '@/App'
import { useRouter } from 'next/router'

const StatusPage = (): JSX.Element => {
  const router = useRouter()
  const { id, lang } = router.query

  return (
    <App>
      <p>
        このページのIDは{id}で言語は{lang}です
      </p>
    </App>
  )
}

export default StatusPage

保存できたらhttp://localhost:3000/statuses/1?lang=jaへアクセスしてみましょう。

ページコンポーネントのファイル名にブラケット記号[]で囲まれた部分がある場合はパス内変数として扱われnext/router経由でクエリーの一部として取得する事が出来ます。なお今回登場したuseRouter()のようなuseから始まる名前の関数はHookと呼ばれているもので、これは関数コンポーネントにおいて状態や副作用を扱う仕組みです。

さてこのuseRouterを使った処理はクライアントサイドで実行されています。目の良い人はページを表示した最初の瞬間はid部分の表示が空になっていた事に気付いていたかもしれません。試しにJavaScriptを無効化した状態でブラウザをリロードしてみましょう。

これはJavaScriptが実行できないBOTがこのページへアクセスした時に見ることになる光景でもあります。それでは下記のように修正してみましょう。

src/pages/statuses/[id].tsx
import React from 'react'
import App from '@/App'
import {
  GetServerSidePropsContext,
  GetServerSidePropsResult
} from 'next'

type StatusPageProps = { id: string; lang: string }

export const getServerSideProps = async (
  context: GetServerSidePropsContext
): Promise<GetServerSidePropsResult<StatusPageProps>> => {
  const { id, lang } = context.query
  if (typeof id !== 'string') {
    return { notFound: true }
  }
  if (typeof lang !== 'string') {
    return { notFound: true }
  }

  return { props: { id, lang } }
}

const StatusPage = (props: StatusPageProps): JSX.Element => {
  return (
    <App>
      <p>
        このページのIDは{props.id}で言語は{props.lang}です
      </p>
    </App>
  )
}

export default StatusPage

最初のコードとの最も大きな違いはgetServerSidePropsという名前でPromiseを返す非同期関数がexportされている事です。

type StatusPageProps = { id: string; lang: string }

export const getServerSideProps = async (
  context: GetServerSidePropsContext
): Promise<GetServerSidePropsResult<StatusPageProps>> => {
  const { id, lang } = context.query
  if (typeof id !== 'string') {
    return { notFound: true }
  }
  if (typeof lang !== 'string') {
    return { notFound: true }
  }

  return { props: { id, lang } }
}

getServerSidePropsはユーザーのリクエストに応じてサーバーサイド(Node.js)で実行されるページコンポーネントの前処理です。今回のようにURLから取得したパラメーターや、あるいは外部のAPIなどから取得したデータなど、外部から取得した情報等を元にしてページコンポーネントへ渡すprops(コンポーネント引数)を組み立てる役割を持っています。(getStaticPropsも同様にサーバーサイドでの前処理を担いますが、これはリクエスト時ではなくビルド時に処理されるという違いがあります。)

またGetServerSidePropsContextはgetServerSidePropsがNext.jsから受け取る変数の型です。サーバーサイドにおいてはこのGetServerSidePropsContextのqueryからslugやURL Queryを取得する事になります。

node_modules/next/types/index.d.ts
export type GetServerSidePropsResult<P> =
  | { props: P }
  | { redirect: Redirect }
  | { notFound: true }

GetServerSidePropsResult<P>getServerSideProps関数が返すべき値の型です。カーソルを合わせた状態でF12キーを押して型定義を見ると分かるようにgetServerSidePropsは、『propsキーまたはredirectキーまたはnotFoundキーを持ったオブジェクト』という3パターンの値を返す事が想定されています。

<P>というタグのようなもので囲まれているPは型定義で利用できる型変数です。今回の例では独自に定義したtype StatusPageProps = { id: string; lang: string }という型をPとして渡しているためGetServerSidePropsResult<StatusPageProps>型は最終的には下記の定義になります。

  | { props: { id: string; lang: string } }
  | { redirect: Redirect }
  | { notFound: true }

このように型変数を利用して定義された型をジェネリック型と呼んだりもします。

なおここまで紹介してきた型をまとめたGetServerSideProps型や、Reactの関数コンポーネントを表現したFC型(FunctionComponent型)といった便利な型も提供されているため、下記のようにより簡潔に書く事もできます。GetServerSideProps型やFC型の詳細については手元の型定義を見てみましょう。

src/pages/statuses/[id].tsx
import React from 'react'
import App from '@/App'
import { GetServerSideProps } from 'next'

type StatusPageProps = { id: string; lang: string }

export const getServerSideProps: GetServerSideProps<StatusPageProps> = async (
  context
) => {
  const { id, lang } = context.query
  if (typeof id !== 'string') {
    return { notFound: true }
  }
  if (typeof lang !== 'string') {
    return { notFound: true }
  }

  return { props: { id, lang } }
}

const StatusPage: FC<StatusPageProps> = (props) => {
  return (
    <App>
      <p>
        このページのIDは{props.id}で言語は{props.lang}です
      </p>
    </App>
  )
}

export default StatusPage

保存できたらJavaScriptを無効化した状態でブラウザをリロードしてみましょう。

ページタイトルを付けてみよう

せっかくなのでページタイトルも付けておきましょう。next/headを使います。

src/pages/statuses/[id].tsx
import React from 'react'
import App from '@/App'
import { GetServerSideProps } from 'next'
+import Head from 'next/head'

type StatusPageProps = { id: string; lang: string }

export const getServerSideProps: GetServerSideProps<StatusPageProps> = async (
  context
) => {
  const { id, lang } = context.query
  if (typeof id !== 'string') {
    return { notFound: true }
  }
  if (typeof lang !== 'string') {
    return { notFound: true }
  }

  return { props: { id, lang } }
}

const StatusPage: FC<StatusPageProps> = (props) => {
+  const title = `このページのIDは${props.id}です`

  return (
    <App>
 +      <Head>
+        <title>{title}</title>
+        <meta property="og:title" content={title} key="ogtitle" />
+      </Head>    
      <p>
        このページのIDは{props.id}で言語は{props.lang}です
      </p>
    </App>
  )
}

export default StatusPage

Next.jsのAPI RouteでAPIを作ってみよう

Next.jsにはAPIルートと呼ばれるWeb API開発のための仕組みが用意されています。次の章ではこのAPIルートを通じてGraphQLサーバーを提供する方法について説明しますが、その準備運動としてこの章ではハードコーディングされたJSONを返すシンプルなAPIを作ってみましょう。

pagesの下に新しくapiというディレクトリを作り、その中にstatus.tsという名前で下記のファイルを作ります。

pages/api/status.ts
import { NextApiRequest, NextApiResponse } from 'next'

const statusApi = (req: NextApiRequest, res: NextApiResponse): void => {
  const result =
    typeof req.query.id === 'string' ? getStatus(req.query.id) : listStatuses()

  res.status(200).json(result ?? {})
}

type Status = { id: string; body: string; author: string; createdAt: Date }
const statuses: Status[] = [
  {
    id: '2',
    body: 'inviting coworkers',
    author: 'jack',
    createdAt: new Date(2021, 4, 2),
  },
  {
    id: '1',
    body: 'just setting up my app',
    author: 'jack',
    createdAt: new Date(2021, 4, 1),
  },
]

export const getStatus = (id: string): Status | undefined =>
  statuses.find((d) => d.id === id)

export const listStatuses = (): Status[] => statuses

export default statusApi

さっそくAPIにアクセスしてみましょう。id1のデータがJSONで表示されていれば成功です。

http://localhost:3000/api/status?id=1

それではこのAPIをページコンポーネントから使ってみましょう。

src/pages/statuses/[id].tsx
import React from 'react'
import App from '@/App'
import { GetServerSideProps } from 'next'
import Head from 'next/head'

type StatusPageProps = {
  status: Status
}
type Status = {
  id: string
  body: string
  author: string
  createdAt: string
}

const isStatus = (data: unknown): data is Status => {
  const d = data as Status
  if (typeof d.id !== 'string') {
    return false
  }
  if (typeof d.body !== 'string') {
    return false
  }
  if (typeof d.author !== 'string') {
    return false
  }
  if (typeof d.createdAt !== 'string') {
    return false
  }

  return true
}

export const getServerSideProps: GetServerSideProps<StatusPageProps> = async (
  context
) => {
  const res = await fetch(
    `http://localhost:3000/api/status?id=${context.query.id}`
  )
  const statusData = (await res.json()) as unknown
  if (!isStatus(statusData)) {
    return { notFound: true }
  }

  return { props: { status: statusData } }
}

const StatusPage: FC<StatusPageProps> = (props) => {
  return (
    <App>
      <Head>
        <title>{props.body}</title>
        <meta property="og:title" content={props.body} key="ogtitle" />
      </Head>
      <h1>{props.body}</h1>
      <p>{props.author}</p>
    </App>
  )
}

export default StatusPage

http://localhost:3000/statuses/1

コードに戻ってgetServerSidePropsから見てみましょう。

export const getServerSideProps: GetServerSideProps<StatusPageProps> = async (
  context
) => {
  const res = await fetch(
    `http://localhost:3000/api/status?id=${context.query.id}`
  )
  const statusData = (await res.json()) as unknown
  if (!isStatus(statusData)) {
    return { notFound: true }
  }

  return { props: { status: statusData } }
}

fetchはリモートリソース取得のための非同期APIです。通常Node.jsでfetchは利用できませんがNext.jsがnode-fetchでのポリフィルを行ってくれるため特に意識しなくても利用が可能です。fetchによってネットワークの向こう側からやってくるstatusDataは、実行時チェックするまで実際にはどんな型なのか未知なのでunknown型として扱っています。

またこのstatusDataをisStatusという関数に通していますが、これはユーザー定義Type Guardと呼ばれるものです。isStatusの本体を見てみましょう。

type Status = {
  id: string
  body: string
  author: string
  createdAt: string
}

const isStatus = (data: unknown): data is Status => {
  const d = data as Status
  if (typeof d.id !== 'string') {
    return false
  }
  if (typeof d.body !== 'string') {
    return false
  }
  if (typeof d.author !== 'string') {
    return false
  }
  if (typeof d.createdAt !== 'string') {
    return false
  }

  return true
}

ユーザー定義のType Guardではこのようにtrue/falseを返すことで、受け取った未知のデータに対して実行時に一定の条件を満たせば対象の型として扱う、といった処理を書くことができます。ただし一定の条件をどこまでチェックするかというさじ加減はプログラマーへ託されているため、あなたが望むなら『どんなdataを受け取ってもStatusとして扱う』ような危険なType Guardを書く自由もあります。

const isStatus = (data: unknown): data is Status => {
  return true
}

Type Guardは詳細に書けば書くほど安全になりますが、Type Guardを書く手間やType Guardの実装にバグが混入する可能性があります。またある時点では正しく実装されていたType GuardがAPI側の変更に追従できず正しくなくなるという事も有りえます。

こういった『ネットワークの向こう側からやってくるリソースの型にまつわる問題』の解決策の一つとして、型を何らかの形で自動生成するというアプローチがあります。次の章で扱うGraphQLライブラリのApolloもまたそういったアプローチを採用しています。

実は同一のNext.jsアプリケーション内でページコンポーネントからAPIルートのAPIを呼ぶ場合fetchを使う必要は必ずしもありません。今回の例であればgetStatusを直接importして利用するのも良いでしょう。

コンポーネントをディレクトリで分類してみよう

これからいくつかのUIコンポーネントを実装していきますが、その前にcomponentsディレクトリを細分化してみましょう。どういったモノがどこに置かれるかというルールとその共通認識は特にチーム開発においては重要です。

pagesディレクトリと違いNext.jsが要求するルールは特に無いので個々の事情に合わせて自由に決める事ができます。またTypeScript環境であれば後からのディレクトリ構造変更やコンポーネントの移動は比較的容易な作業なので、アプリケーションの成長に応じて調整していくのも良いでしょう。

本書ではアトミックデザインの語彙から一部を借りて下記のディレクトリ構成とルールで始めます。

├─components/
| ├─atoms/
| ├─molecules/
| └─organisms/
├─pages/
  • atomsには、機能的にそれ以上分割できない最小のコンポーネントを配置する
  • moleculesには、atomsを組合せる事で成立するコンポーネントを配置する
  • organismsには、moleculesを組み合わせる事で成立するコンポーネントを配置する
  • organismsはmoleculesに加えてatomsに直接依存しても良い
  • components配下のコンポーネントで内部状態を持っても良いがその場合は、ファイル内でステートレスなコンポーネントと、それを利用したステートフルなコンポーネントとに分割する
  • APIコールやContextの提供は原則ページコンポーネントまたは_appで行う
コラム:アトミックデザインを元にしたReactコンポーネントの分類にまつわる話

アトミックデザインは元来ビジュアルデザインのためのコンポーネントベースの方法論であり、そのコンポーネント分類はあくまでデザイン要素としての分類という性質が強いものです。そのコンポーネントがどのように実装されているのか(例えばドメインと接触のあるコンポーネントか、内部状態を持ったコンポーネントかなど)や、経年に対する分類の不変性、分類の厳格さといった開発者にとっての関心事はあまり考慮されていません。

これは

  • 特定の技術(例えばReact)に依存した概念ではないので幅広く適用できる、
  • 開発者でなくとも分類を扱えるので職種を跨った共通言語として利用できる

という強みでもありますが、その一方でチームによっては扱いにくかったり個人の解釈違いによって分類が混乱する事があります。このためチームによっては本書のように分類に対して独自ルールを追加する事で利便性を向上させたり分類の厳格さを補強しているケースがあります。

Next.jsにおいての注意点

アトミックデザインにおけるPagesはNext.jsにおけるpagesディレクトリやページコンポーネントと直接対応した概念ではありません。Next.jsのページコンポーネントに具体的なpropsを与えた最終的なレンダリング結果(またはそれを表現したデザインカンプ)がアトミックデザインにおけるPagesです。

またアトミックデザインにおけるTemplatesは、props抜きでのページコンポーネントの表示(またはそれを表現したワイヤーフレーム)というのが一番近いかもしれません。再利用(複数のページコンポーネントで同じTemplateの利用)が想定されないのであればページコンポーネントにその役割を持たせてしまうのも良いかもしれません。

Material-UIを使ってみよう

このセクションの記述は現在MUI v5.0.0-alpha.37を元にしているため、MUI v5正式版とは異なった手順や内容が含まれる可能性があります。

そろそろ我々のアプリケーションの殺風景な見栄えが気になりはじめた頃でしょうか。このセクションでは少しばかりのデザインと構造を付け加える事にしましょう。

Material-UI(以下MUI)はマテリアルデザインと呼ばれるGoogle社が提唱するデザインシステムを採用したReactコンポーネント集です。下記を実行してインストールします。

npm install @material-ui/core@next @material-ui/styles@next @material-ui/icons@next @emotion/react @emotion/styled @emotion/server @emotion/cache

インストールが終わったら先ずはこのアプリケーション用のテーマを作りましょう。テーマはアプリケーション全体で共通して使うテーマカラーやフォントなどスタイル設定を共有するための仕組みです。本書では扱いませんがダークモードなどもテーマ機能を通して実現する事ができます。

src/default-theme.ts
import { createTheme, ThemeOptions } from '@material-ui/core'

const defaultThemeOptions: ThemeOptions = {
  palette: { primary: { main: '#1DA1F2', contrastText: '#FFFFFF' } },
}
export const defaultTheme = createTheme(defaultThemeOptions)

作ったテーマをNext.jsアプリケーション内で利用できるようpagesディレクトリに_app.tsxを作成しましょう。_app.tsxは全てのページコンポーネントの親となるコンポーネントです。またここでは同時にスタイルのSSRに関する設定も行っています。

src/pages/_app.tsx
import React from 'react'
import type { AppProps } from 'next/app'
import { CssBaseline, ThemeProvider } from '@material-ui/core'
import createCache from '@emotion/cache'
import { CacheProvider } from '@emotion/react'
import { defaultTheme } from 'default-theme'

// @see https://next.material-ui.com/guides/server-rendering/
const cache = createCache({ key: 'css', prepend: true })
cache.compat = true

const MyApp = ({ Component, pageProps }: AppProps): JSX.Element => {
  return (
    <CacheProvider value={cache}>
      <ThemeProvider theme={defaultTheme}>
        <CssBaseline />
        <Component {...pageProps} />
      </ThemeProvider>
    </CacheProvider>
  )
}
export default MyApp

合わせて_document.tsxも作成します。

src/pages/_document.tsx
import * as React from 'react'
import { CacheProvider } from '@emotion/react'
import createCache from '@emotion/cache'
import Document, { Html, Head, Main, NextScript } from 'next/document'
import createEmotionServer from '@emotion/server/create-instance'
import { defaultTheme } from 'default-theme'

const getCache = () => {
  const cache = createCache({ key: 'css', prepend: true })
  cache.compat = true
  return cache
}

const theme = defaultTheme
const lang = 'ja'

export default class MyDocument extends Document {
  render(): JSX.Element {
    return (
      <Html lang={lang}>
        <Head>
          {/* PWA primary color */}
          <meta name="theme-color" content={theme.palette.primary.main} />
          <link
            rel="stylesheet"
            href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
          />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

// `getInitialProps` belongs to `_document` (instead of `_app`),
// it's compatible with static-site generation (SSG).
MyDocument.getInitialProps = async (ctx) => {
  // Resolution order
  //
  // On the server:
  // 1. app.getInitialProps
  // 2. page.getInitialProps
  // 3. document.getInitialProps
  // 4. app.render
  // 5. page.render
  // 6. document.render
  //
  // On the server with error:
  // 1. document.getInitialProps
  // 2. app.render
  // 3. page.render
  // 4. document.render
  //
  // On the client
  // 1. app.getInitialProps
  // 2. page.getInitialProps
  // 3. app.render
  // 4. page.render

  const originalRenderPage = ctx.renderPage

  const cache = getCache()
  const { extractCriticalToChunks } = createEmotionServer(cache)

  ctx.renderPage = () =>
    originalRenderPage({
      // Take precedence over the CacheProvider in our custom _app.js
      // eslint-disable-next-line react/display-name
      enhanceComponent: (Component) => (props) =>
        (
          <CacheProvider value={cache}>
            <Component {...props} />
          </CacheProvider>
        ),
    })

  const initialProps = await Document.getInitialProps(ctx)
  const emotionStyles = extractCriticalToChunks(initialProps.html)
  const emotionStyleTags = emotionStyles.styles.map((style) => (
    <style
      data-emotion={`${style.key} ${style.ids.join(' ')}`}
      key={style.key}
      // eslint-disable-next-line react/no-danger
      dangerouslySetInnerHTML={{ __html: style.css }}
    />
  ))

  return {
    ...initialProps,
    // Styles fragment is rendered after the app and page rendering finish.
    styles: [
      ...React.Children.toArray(initialProps.styles),
      ...emotionStyleTags,
    ],
  }
}

以上で準備は終了です。さっそくテーマを利用したコンポーネントを一つ作ってみましょう。

src/components/atoms/banners/MediumRectangleDummy.tsx
import React, { FC } from 'react'
import { Box, useTheme } from '@material-ui/core'
import { SxProps } from '@material-ui/system'

export const MediumRectangleDummyBanner: FC = () => {
  const theme = useTheme()
  const style: SxProps = {
    width: '300px',
    height: '250px',
    backgroundColor: theme.palette.primary.main,
    color: theme.palette.primary.contrastText,
    textAlign: 'center',
    lineHeight: '250px',
  }

  return <Box sx={style}>FOR SALE</Box>
}

コンポーネントのスタイリング手法についてはいくつかの選択肢がありますが、本書ではMUIが提供しているsx propを利用します。

本書ではファイル名について、通常のコンポーネントはReact公式ドキュメントに倣いPascalCaseを、ファイル名がそのままURLとしても使われるNext.jsのページコンポーネントについては、Next.js公式ドキュメントに倣いkebab-caseをそれぞれ使用します。

その他のコンポーネントも駆け足で作っていきましょう。MUIが提供するコンポーネントについての詳細は公式ドキュメントを確認してください。

src/components/moleclues/VerticalBanners.tsx
import React, { FC } from 'react'
import { Box } from '@material-ui/core'
import { MediumRectangleDummyBanner } from '@/atoms/banners/MediumRectangleDummy'

export const VerticalBanners: FC = () => (
  <Box component="div">
    <Box pb={2}>
      <MediumRectangleDummyBanner />
    </Box>
    <Box>
      <MediumRectangleDummyBanner />
    </Box>
  </Box>
)

BoxはMUIが提供しているレイアウト用のコンポーネントです。デフォルトだと実体はdiv要素として出力されますが<Box component="span">のように任意の要素を指定する事もできます。

src/components/moleclues/StatusCard.tsx
import React, { FC } from 'react'
import { Card, CardContent, Typography } from '@material-ui/core'
import Link from 'next/link'

type StatusCardProps = {
  id: string
  body: string
  author: string
  createdAt: string
  linkEnabled?: boolean
}

export const StatusCard: FC<StatusCardProps> = ({
  id,
  body,
  author,
  createdAt,
  linkEnabled = true,
}) => {
  const date = new Date(createdAt).toLocaleString()

  return (
    <Card>
      <CardContent>
        <Typography gutterBottom variant="h3" component="div">
          {body}
        </Typography>
        <Typography gutterBottom variant="subtitle2" component="div">
          {author}
        </Typography>
        <Typography gutterBottom variant="subtitle2" component="div">
          {linkEnabled ? (
            <Link href={`/statuses/${id}`} prefetch={false}>
              <a href={`/statuses/${id}`}>{date}</a>
            </Link>
          ) : (
            date
          )}
        </Typography>
      </CardContent>
    </Card>
  )
}

LinkはNext.jsのページコンポーネント間でリンクを張るためのコンポーネントです。aタグのみを使った一般的なリンクがサーバーから新しいドキュメントを丸ごと貰い再描画・遷移するのに対して、Linkコンポーネントを使ったリンクでは必要なコンポーネントのみ置き換わる高速なクライアントサイド遷移やprefetchがサポートされます。

src/components/atoms/listItem/Home.tsx
import React, { FC } from 'react'
import {
  ListItem,
  ListItemIcon,
  ListItemText,
  useTheme,
} from '@material-ui/core'
import HomeIcon from '@material-ui/icons/Home'
import Link from 'next/link'

type HomeListItemProps = {
  selected?: boolean
}

export const HomeListItem: FC<HomeListItemProps> = ({ selected = false }) => {
  const theme = useTheme()

  const item = (
    <ListItem button selected={selected}>
      <ListItemIcon>
        <HomeIcon sx={{ color: theme.palette.primary.main }} />
      </ListItemIcon>
      <ListItemText primary="ホーム" />
    </ListItem>
  )

  if (selected) {
    return item
  }

  return (
    <Link href="/" prefetch={false}>
      <a href="/">{item}</a>
    </Link>
  )
}
src/components/moleclues/NavigationList.tsx
import React, { FC } from 'react'
import { List } from '@material-ui/core'
import { HomeListItem } from '@/atoms/listItem/Home'

type NavigationListProps = {
  currentRouteName?: string
}

export const NavigationList: FC<NavigationListProps> = ({
  currentRouteName,
}) => (
  <List>
    <HomeListItem selected={currentRouteName === 'home'} />
  </List>
)

src/components/atoms/PermanentLeftDrawer.tsx
import React, { FC } from 'react'
import { Drawer } from '@material-ui/core'

type PermanentLeftDrawerProps = {
  children: JSX.Element
}

export const PermanentLeftDrawer: FC<PermanentLeftDrawerProps> = ({
  children,
}) => {
  const w = 200
  return (
    <Drawer
      variant="permanent"
      anchor="left"
      sx={{
        width: w,
        flexShrink: 0,
        '& .MuiDrawer-paper': {
          width: w,
          boxSizing: 'border-box',
        },
      }}
    >
      {children}
    </Drawer>
  )
}

今回propsに登場したchildrenという名前は特別な意味を持っています。これはこのコンポーネントが子コンポーネントを受け取れる事を意味しています。例えば

<PermanentLeftDrawer>
  <Typography>おぎゃー</Typography>
</PermanentLeftDrawer>

のように自身のタグで囲んだ要素(この場合は<Typography>おぎゃー</Typography>)を自身の子要素としてprops.childrenで参照する事ができます。なおchildrenには1つの要素しか受け取ることができないという制約があります。(孫要素はいくつあっても問題ありません)

実はReact17までのReact.FC型のprops部分には何もしなくても暗黙的にchildrenが定義されていますが、これはReact18で削除される予定です。このためchildrenを利用するコンポーネントではpropsで明示的にchildrenを定義しておくとよいでしょう

src/components/atoms/layouts/TwoColumnLayout.tsx
import React, { FC } from 'react'
import { Grid } from '@material-ui/core'

type TwoColumnLayoutProps = {
  children: JSX.Element
  rightColumnContents: JSX.Element
}

export const TwoColumnLayout: FC<TwoColumnLayoutProps> = ({
  children,
  rightColumnContents,
}) => (
  <Grid container direction="row" spacing={2}>
    <Grid item xs>
      {children}
    </Grid>
    <Grid item xs>
      {rightColumnContents}
    </Grid>
  </Grid>
)

GridはレスポンシブなグリッドシステムのためにMUIが提供しているコンポーネントです。

src/components/organisms/layouts/BirdHouseLayout.tsx
import React, { FC } from 'react'
import { Box } from '@material-ui/core'
import { PermanentLeftDrawer } from '@/atoms/PermanentLeftDrawer'
import { NavigationList } from '@/moleclues/NavigationList'
import { TwoColumnLayout } from '@/atoms/layouts/TwoColumnLayout'
import { VerticalBanners } from '@/moleclues/VerticalBanners'

type BirdHouseLayoutProps = {
  children: JSX.Element
  currentRouteName?: string
}

export const BirdHouseLayout: FC<BirdHouseLayoutProps> = ({
  children,
  currentRouteName,
}) => (
  <Box sx={{ display: 'flex' }}>
    <PermanentLeftDrawer>
      <NavigationList currentRouteName={currentRouteName} />
    </PermanentLeftDrawer>
    <Box
      component="main"
      sx={{ flexGrow: 1, p: 3 }}
    >
      <TwoColumnLayout rightColumnContents={<VerticalBanners />}>
        {children}
      </TwoColumnLayout>
    </Box>
  </Box>
)

これで必要なパーツ(コンポーネント)は全て揃いました。StatusPageに組み込んでみましょう。(見た目以外は変更しません)

src/pages/statuses/[id].tsx
const StatusPage: FC<StatusPageProps> = (props) => (
  <BirdHouseLayout>
    <>
      <Head>
        <title>{props.status.body}</title>
        <meta property="og:title" content={props.status.body} key="ogtitle" />
      </Head>
      <StatusCard {...props.status} linkEnabled={false} />
    </>
  </BirdHouseLayout>

<></>という空タグのような見た目のものはフラグメントと呼ばれるものです。

今回の例ではHeadとStatusCardという2つの要素をBirdHouseLayoutのchildrenとして渡そうとしていますが、先述したようにchildrenには一つの要素しか受け取れないという制約があります。こういった場合には2つの要素をdivタグ等で包み1つの要素としてまとめる事もできますが、フラグメントを利用すると実際のDOMには無駄な要素を増やさずに1つの要素として扱わせる事が可能になります。

さて、今まで作ってきたコンポーネントとAPIを利用してindex.tsxにも手を加えてみましょう。下記の内容でHomePageコンポーネントを保存します。

src/pages/index.tsx
import React, { FC } from 'react'
import { StatusCard } from '@/moleclues/StatusCard'
import { BirdHouseLayout } from '@/organisms/layouts/BirdHouseLayout'
import { Box } from '@material-ui/core'
import { GetServerSideProps } from 'next'
import Head from 'next/head'

type HomePageProps = {
  statuses: Status[]
}

type Status = {
  id: string
  body: string
  author: string
  createdAt: string
}

const isStatuses = (data: unknown): data is Status[] => {
  // 内緒だよ
  return true
}

export const getServerSideProps: GetServerSideProps<HomePageProps> =
  async () => {
    const res = await fetch(`http://localhost:3000/api/status`)
    const statusesData = (await res.json()) as unknown
    if (!isStatuses(statusesData)) {
      return { notFound: true }
    }

    return { props: { statuses: statusesData } }
  }

const HomePage: FC<HomePageProps> = ({ statuses }) => {
  return (
    <BirdHouseLayout currentRouteName="home">
      <>
        <Head>
          <title>最新ステータス</title>
          <meta property="og:title" content="最新ステータス" key="ogtitle" />
        </Head>
        {statuses.map((s) => (
          <Box key={s.id} pb={2}>
            <StatusCard {...s} />
          </Box>
        ))}
      </>
    </BirdHouseLayout>
  )
}

export default HomePage

このページではprops.statusesというArrayを元にmapメソッドで複数のStatusCardを出力しようとしています。Reactではこういった繰り返し要素に対してはkey属性で各コンポーネントに一意なキー文字列を付ける事が推奨されています。これは繰り返し要素(連続する子要素)に対して部分更新を可能としパフォーマンスを向上させるためのものです。

スタイルのSSRが正常に動作している事を確認するため、JavaScriptを無効にした状態でもスタイルが崩れない事を確認しておくと良いでしょう。