Next.js で静的サイトを作成する

経緯

拠所無き事情により新しいフレームワークを模索していたのですが、折角なので今更ながらメジャーストリームである Next.js に触っておこうかと思い立ちました。

手順

Node.js と Yarn は既に開発環境にあるので、サクッと yarn create next-app で Next.js のアプリを作ります。

> yarn create next-app
      - create-next-app
√ What is your project named? ... SAMPLEAPP
√ 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 Tailwind CSS with this project? ... No / Yes
√ Would you like to use `src/` directory with this project? ... No / Yes
√ Use App Router (recommended)? ... No / Yes
√ Would you like to customize the default import alias? ... No / Yes
Creating a new Next.js app in PATH\TO\PROJECT\SAMPLEAPP.

Using yarn.

Initializing project with template: app

## 略

対話式でアプリの設定を訊かれるので答えて初期設定を済ませます。折角なので TypeScript も入れましょう。 Rust を触ってガチガチに固められた型の世界も入門したので何とかなるでしょう。

> yarn run dev

開発中はこれでホットリロードしつつ開発とデバッグを繰り返します。

> yarn run build

最後はビルド。 Yarn が分かっていて package.json を読めば何とかなります。

今回の成果物は Markdown の文章を一定のフォーマットに合わせて変換する簡易的な変換ツールです。オリジナルと変換後出力先の Textarea があって、ボタンクリック時に変換するという至ってシンプルなもの。

それゆえ Redux はいらないと踏んでいたのですが作り方的にコンポーネントを細分化しすぎて State 管理が思ったよりも煩雑になってしまったのが反省点。

メモ

幾つか躓いたりした部分のメモ。

TypeScript の構文

function OriginalForm(props: { name: string, head: string, muted: string }) {
    // 略
}
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const handleClickSend = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
    // 略
}

この辺りが割と使った表現。配列やオブジェクトの要素の型を指定するときが <> で括る形式ですね。 Rust もそういえばこの構文でしたね。

日本語入力

島嶼は onChange で実装しようとしたのですが、日本語入力で躓いてしまった(変換できない)ので Button コンポーネントと onClick に方針転換。今回は限られた時間でサクッと作りたかったので根本的な解決と言うより回避ですね。

ref

import { useContext, useRef } from 'react';

// 初期値を与えること。 null でOK
const inputRef = useRef<HTMLTextAreaElement | null>(null);
// useContext
const orgText = useContext(OriginalText);
// click時
const handleClickSend = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
  e.preventDefault();
  // 型として setText が string を受け付けるようにしているので null が渡らないように潰しておく
  const text = typeof inputRef.current?.value === 'string' ? inputRef.current?.value : '';
  orgText?.setText(text);
<Form.Control
    as="textarea"
    rows={10}
    ref={inputRef}
    defaultValue=""
/>

ref 及び useRef の使用。

プロパティ ‘name’ は型 ‘EventTarget’ に存在しません。

プロパティ ‘name’ は型 ‘EventTarget’ に存在しません。

というエラーメッセージ。

// 型として setText が string を受け付けるようにしているので null が渡らないように潰しておく
const text = typeof inputRef.current?.value === 'string' ? inputRef.current?.value : '';

必ず String 型が入るようにして回避。

You’re importing a component that needs useState. It only works in a Client Component but none of its parents are marked with “use client”, so they’re Server Components by default.

You’re importing a component that needs useState. It only works in a Client Component but none of its parents are marked with “use client”, so they’re Server Components by default.

'use client';

'use client'; を指定してクライアントサイドであることを教えればOK。

rning: You provided a value prop to a form field without an onChange handler. This will render a read-only field. If the field should be mutable use defaultValue. Otherwise, set either onChange or readOnly.

rning: You provided a value prop to a form field without an onChange handler. This will render a read-only field. If the field should be mutable use defaultValue. Otherwise, set either onChange or readOnly.

value 属性ではなく defaultValue 属性を指定する、か、 readOnly 属性を付ければOK。今回は出力先の表示専用の Textarea で読み取り専用で良かったので後者で回避。

build 時に Type ‘Context’ is not assignable to type ‘never’. 等のエラー

build 時に以下のエラーが発生。

Type error: Type 'OmitWithTag<typeof import("PATH/TO/PROJECT/src/app/page"), "metadata" | "default" | "config" | "generateStaticParams" | "revalidate" | "dynamic" | ... 4 more ... | "generateMetadata", "">' does not satisfy the constraint '{ [x: string]: never; }'.
  Property 'OriginalText' is incompatible with index signature.
    Type 'Context<never>' is not assignable to type 'never'.

これが最も嵌まった部分ですが、 page.tsxcreateContextexport していたためと判明。

page.tsx は Next.js 上でエントリポイント的な特殊なページらしく(ドキュメントを読んでいない)、 export default function Home() {} 以外の export があるとマズい模様。ラッパーコンポーネントを用意して createContext するtsxを1つ下の子コンポーネントに機能を丸ごとスライドすることで回避。

確かに、冷静に考えれば初期状態である小文字始まりのコンポーネントっていかにも特殊な役割がありそうなモノですよね……。

静的サイトとして生成

Next.js 13.3 以降だとそれまでとはやり方が変わっており、しかも比較的最近のバージョンゆえネット上の記事が有効ではなかった部分(13.3以前のやり方の記事が多くヒットした)。

/** @type {import('next').NextConfig} */
const nextConfig = {
    output: 'export',
}

module.exports = nextConfig

next.config.jsnextConfigoutput: 'export' を指定すればOK。出力先は out/ ディレクトリになります。

サブディレクトリにデプロイ

今回は本当にサクッとした静的サイトで、レンタルサーバのスペースで動かそうかとか考えていたレベルなので逆にデフォルトのドキュメントルートからの絶対パス指定だと引っかかってしまった部分。

まあ、アプリならば通常ドキュメントルートにデプロイしますよね、というところは本当そうだと思うのですが。

/** @type {import('next').NextConfig} */
const nextConfig = {
    output: 'export',
    basePath: '/PATH/TO/PROJECT',
}

module.exports = nextConfig

next.config.jsnextConfigbasePath でドキュメントルートからのパスを指定すればOK。


ざっとこんなところで無事アプリを動かすことができました。日曜大工ならぬ日曜プログラミングで何とかなるレベルで良かった。

参考

Next.js

Google Fonts

React Bootstrap

props

TypeScript の型の書き方

Form

useState

useContext

日本語入力

ref

プロパティ ‘name’ は型 ‘EventTarget’ に存在しません。

プロパティ ‘name’ は型 ‘EventTarget’ に存在しません。

e.target, e.currentTarget

You’re importing a component that needs useState. It only works in a Client Component but none of its parents are marked with “use client”, so they’re Server Components by default.

You’re importing a component that needs useState. It only works in a Client Component but none of its parents are marked with “use client”, so they’re Server Components by default.

rning: You provided a value prop to a form field without an onChange handler. This will render a read-only field. If the field should be mutable use defaultValue. Otherwise, set either onChange or readOnly.

rning: You provided a value prop to a form field without an onChange handler. This will render a read-only field. If the field should be mutable use defaultValue. Otherwise, set either onChange or readOnly.

build 時に Type ‘Context’ is not assignable to type ‘never’. 等のエラー

build 時に以下のエラーが発生。

Type error: Type 'OmitWithTag<typeof import("PATH/TO/PROJECT/src/app/page"), "metadata" | "default" | "config" | "generateStaticParams" | "revalidate" | "dynamic" | ... 4 more ... | "generateMetadata", "">' does not satisfy the constraint '{ [x: string]: never; }'.
  Property 'OriginalText' is incompatible with index signature.
    Type 'Context<never>' is not assignable to type 'never'.

build, SSG

サブディレクトリへのデプロイ、 basePath

正規表現

変数、エスケープ

オブジェクトは ‘null’ である可能性があります。

配列

この記事を書いた人

アルム=バンド

フロントエンド・バックエンド・サーバエンジニア。LAMPやNodeからWP、Gulpを使ってejs,Scss,JSのコーディングまで一通り。たまにRasPiで遊んだり、趣味で開発したり。