Created my site with Next.js

はじめに
Ruby + JekyllによるGihubPagesは既にあるのですが、宮崎版コロナ対策サイトでVueに触れ、勉強がてら実際にJSによるサイト作成をする事にしました。
- JSの現状を知るために参照した主サイト
- 2020年 React軸で学ぶべき技術 from mizchi's blog
- ユーザー体験を向上させるサーバーサイドレンダリングJavaScript — 歴史と利点
自分
大学研究でcppを利用しただけの、農学部卒。
ただいま無職、転職活動中(ここ2か月は自粛でstay home
作成に当たって
React とNext.js のtutorial と docs を一通りやりました。
サイト自体の目的
- 経歴や作成したもののリンクをまとめる
- GithubPagesやQiita、Gistへの投稿物を一か所にまとめる
- Markdonwによるページ作成
リンク
技術・要件など
- React.js, Next.js
- マークダウン変換
- remarkjs/reamrk
- あとでmdx-js/mdxに変更するかも
- シンタックスハイライト
- UIコンポーネント
- ダークテーマ
- マークダウン変換
環境
- vm:virtualbox + vagrant
- OS: Ubuntu18.04 bionic
- node -v :v12.16.1
- yarn -v :1.22.4
実作業
yarn create next-app
yarn create next-app next-portfolio
# =>
# ? Pick a template › - Use arrow-keys. Return to submit.
# ❯ Default starter app
# Example from the Next.js repo
Example from the Next.js repo
- amp類
- amp, amp-story, amp-first
- google analytics類
- with-google-analytics, with-google-analytics-amp
- aws類
- with-aws-amplify, with-aws-amplify-typescript
- その他: api類, custome server類, preact及び多数(多すぎるので割愛
- 参照:Github: zeit/next.js/example
Default starter appの場合
今回はReact Next.jsの勉強も兼ねているので、defaultの方を利用した。
yarn dev
すると

# directory
- public
- favicon.ico, zeit.svg
- pages
- index.js
- package.json
- node_modules
- README.md
- yarn.lock
// package.json
{
"name": "next-portfolio",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "9.3.5",
"react": "16.13.1",
"react-dom": "16.13.1"
}
}
Material-UI 導入
見た目重視でmaterial-uiを導入し、主にサイドバーのpermanent / swipeable drawerとGridに使用。
yarn add @material-ui/core
yarn add @material-ui/icons
create src/pages/index.jsx
src
ディレクトリを作成し、下にpages
を収める。src/components/Layout.jsx
の作成
複数ページで共通デザインとなるLayout.jsx
を作成する。ここでは省略したが、<aside />
の中には、material-ui
を利用したpermanent-drawerとモバイル用のswipeable-drawerを実装した。
/src/components/Layout.jsx
// src/components/Layout.jsx
import React from 'react'
import Link from 'next/link'
import { makeStyles, useTheme } from '@material-ui/core/styles'
import Hidden from '@material-ui/core/Hidden'
import SwipeableDrawer from '@material-ui/core/SwipeableDrawer'
import Drawer from '@material-ui/core/Drawer'
import DoubleArrowIcon from '@material-ui/icons/DoubleArrow'
import { List, ListItem, ListItemIcon, ListItemText, Divider } from '@material-ui/core'
import HomeIcon from '@material-ui/icons/Home'
import { MyDrawerList } from '../components/MyDrawerList'
const drawerWidth = 250
const useStyles = makeStyles((theme) => ({
// ...
}))
export function Layout({ children }) {
// ...
const [state, setState] = React.useState({
left: false,
})
// swipeable-drawerの開閉を制御するボタン
const toggleDrawer = (anchor, open) => (event) => {
if (event && event.type === 'keydown' && (event.key === 'Tab' || event.key === 'Shift')) {
return
}
setState({ ...state, [anchor]: open })
}
const HomeDrawerList = () => {
return (
<MyDrawerList>
<List>
<Link href='/'>
<ListItem button>
<ListItemIcon><HomeIcon /></ListItemIcon>
<ListItemText primary='Home' />
</ListItem>
</Link>
// ...
</List>
</MyDrawerList>
)
}
return (
<React.Fragment key='left'>
<Hidden lgUp>
// モバイル端末用
// if display-width > 1280px, display: none
<SwipeableDrawer anchor='left' open={state['left']}
onClose={toggleDrawer('left', false)} onOpen={toggleDrawer('left', true)}
>
<div className='swipeableList' role='presentation'
onClick={toggleDrawer('left', false)} onKeyDown={toggleDrawer('left', false)}
>
<HomeDrawerList />
</div>
</SwipeableDrawer>
<footer>
<button onClick={toggleDrawer('left', true)}>
<DoubleArrowIcon color='secondary' style={{ fontSize: 34 }} />
</button>
</footer>
</Hidden>
<Hidden mdDown>
// 非モバイルディスプレイ用
// if device-width < 1280px, display:none
<aside>
<Drawer className='permanentDrawer' variant='permanent' anchor='left'>
<HomeDrawerList />
</Drawer>
</aside>
</Hidden>
<main className={classes.contents}>
{children}
</main>
<style jsx>{`
// ...
`}</style>
</React.Fragment>
)
}
pages/index.jsx
の作成
まだReact等に不慣れなので、pages/index.js
にサイト1ページ目を作りこんで、後からcomponentに分割する方式をとった。
色々試した結果、サイトトップにあたる pages/index.jsx
は下の様になった。また、<head><meta/></head>
用のデータは/public/manifest.json
から持ってくる事にした。
// src/pages/index.jsx
import React from 'react'
import Head from 'next/head'
import { Layout } from '../components/Layout'
import { Top, About, History, Works } from '../components/HomeContents'
const manifest = require('../../public/manifest.json')
export default function Home() {
return (
<>
<Layout>
<Head>
<title>{manifest.name}</title>
<meta name='title' content={manifest.name} />
<meta name='description' content={manifest.description} />
<meta property='og:title' content={manifest.name} />
<meta property='og:description' content={manifest.description} />
<meta property='og:image' content={`${manifest.vercel}/assets/prtsc700.jpg`} />
<meta property='og:url' content={`${manifest.vercel}`} />
</Head>
<Top />
<About />
<History />
<Works />
</Layout>
<style jsx global>{`
// ...
`}</style>
</>
)
}
/src/components/HomeContetnts.jsx
// src/components/HomeContetnts.jsx
import React from 'react'
import Link from 'next/link'
import Grid from '@material-ui/core/Grid'
export function Top() {
return ( <section id='top' className='topContainer' /> )
}
export function About() {
return (
<section id='about' className='content'>
<h2>About</h2>
<Grid container spacing={4}>
<Grid item md={12} lg={5}>
<picture>
...
</picture>
</Grid>
<Grid item md={12} lg={7}>
<p>My name is Kawano Yudai.</p>
<p>I graduated from Miyazaki Universiy as Bachelor of Agriculture.</p>
<p>I belonged to agricultural engineering lablatory and studied crop row detecting tech by image processing with C++ and OpenCV.</p>
<p style={{ color: '#F48FB1' }}><em>Now, I'm seeking job as developer. Please contact me from left drawer.</em></p>
</Grid>
</Grid>
</section>
);
}
export function Works() {
return ( <section id='works' className='content' /> )
}
export function History() {
return ( <section id='history' className='content' /> )
}
_app.jsx, _document.jsx, 404.jsx
参照
-
_app.jsx
- global cssを追加する場所
-
_document.jsx
- SSRされる箇所なので、
onclick
などイベントハンドラは動かない <Main />
の外側にあるコンポーネントはブラウザによる初期化がされないので、Appロジック等はapp.jsx
に記述<title>
や<Head />
、styled-jsx
を書いちゃ駄目。
- SSRされる箇所なので、
Posts周辺の作成
ダイナミックルーティング
ディレクトリ構成
- pages (*directory)
- index.jsx
- posts (*directory)
- hoge.jsx
- [id].jsx
また、/pages/posts/[id].jsx
は
// /pages/posts/[id].jsx
import useRouter from 'next/route'
export default function Post(){
const router = useRouter()
const { id } = router.query
return <p>Post: {id}</p>
}
ファイル名に[]
が付いてるので変に見えるが。例えば
localhost:3000/posts/hoge/
にアクセスするとpages/posts/hoge.jsx
が読み込まれるlocalhost:3000/posts/foobar
だと、pages/posts/foobar.jsx
が読み込まれ、

dynamic route
とLink( next/link )
を併用する時は、href
に合わせてas
も使う。
getStaticProps, getStaticPaths
今回はmdファイルを/src/pages/docs
に入れる。
baseUrl/posts
へのアクセス時は、docs下のmdファイルを読込み、posts一覧の出力baseUrl/posts/[id]
の場合は、同様にして、post単体の出力baseUrl/tags
の場合は、同様にpostsで使用されている投稿タグ一覧の出力baseUrl/tags/[tag]
なら、同タグを使用するposts一覧を出力- docs配下に無いmdファイル名にアクセスした場合は、
404
ページ出力がsrc/pages/docs/xxx.md
という外部データに依存した静的ページ出力をしたいので、getStaticProps
とgetStaticPaths
を使用した。
-
getStaticProps
- 出力ページのコンテンツが外部データに依存している時に使用
-
getStaticPaths
- 出力ページ中のリンクが外部データに依存している時に使用
実装は下を参照しながらしました。タグの方は自分で用意しましたが。
Next.jsのチュートリアルのこのページ

tagsページのスタイルが未だ・・・

マークダウン
- 実現したい事
- QiitaやGist等での投稿を可能な限り手間なく集約したい
mdxjs/mdx
なら、ファイル中にimport
やexport
等のjsを組み込める。
- QiitaやGist等での投稿を可能な限り手間なく集約したい
- 最終的に利用したもの。
- jonschlinkert/ gray-matter
- processor: remarkjs/ remark
- highlighter: highlight.js
構文木について、しっかり学ばねばと思いました。
/src/lib/posts.jsx
// /src/lib/posts.jsx
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
import remark from 'remark'
import html from 'remark-html'
export async function getPostData(id) {
const fullPath = path.join(postsDirectory, `${id}.md`)
const fileContents = fs.readFileSync(fullPath, 'utf8')
const matterResult = matter(fileContents)
const LowerCaseTags = matterResult.data.tags.map((tag) => (tag.toLowerCase()))
const highlight = require('remark-highlight.js')
const processedContent = await remark()
.use(highlight)
.use(html)
.process(matterResult.content)
const contentHtml = processedContent.toString()
return {
id,
contentHtml,
LowerCaseTags,
...matterResult.data,
}
}
meta

以前にrubyとjekyllで作ったgithubpagesと比較して、syntax-highlightが粗いので改善が必要

# front-matter
---
date: '2020-05-26'
author: Kawano Yudai
title: 'Qiita: Next.jsでポートフォリオサイトを作成した'
tags: [Qiita, React, Next.js]
image: '/assets/posts/202003/miyazaki-oss1.jpg'
slide: false
---
SNSシェアボタン
// ./src/pages/posts/[id].jsx
<button className='twitter'>
<a href={`https://twitter.com/share?text=${postData.title}&hashtags=react,nextjs&url=https://next-portfolio-blue.now.sh/posts/${postData.id}&related=not_you_die`}
target='_blank' rel='noopener noreferrer'><TwitterIcon /></a>
</button>
<button className='hatena'>
<a href={`https://b.hatena.ne.jp/entry/https://next-portfolio-blue.now.sh/posts/${postData.id}`} className='hatena-bookmark-button' data-hatena-bookmark-layout='touch-counter'
title={postData.title} target='_blank' rel='noopener noreferrer'><HatenaIcon /></a>
</button>
UPDATE
README.md
@ 2020-05-27
Qiita投稿の公開に当たり、README.md
を充実させた
npm install 禁止
@ 2020-05-27
特に理由はないがnpm
の仕様を禁じることにした。
Custom Domain
@ 2020-06-01
- google domainで購入
- Vercel側でドメインを変更
- Google Domain側でdnsをvercel用に変更
- ns1.vercel-dns.com
- ns2.vercel-dns.com
Google Analytics
@ 2020-06-05
GoogleAnalytics側でIDを取得し、_app.jsx
と_document.jsx
を上コードに従って修正する。
PWA implimentation
next-offline
@ 2020-06-05
next-offline
を利用した。上リポジトリでも記載してあるが、Vercel( Now )のv1とv2で動作が違う。但し、現在はv2オンリーなので、同レポジトリ内にあるpackages/now2-exampleのnow.json
とnext.config.json
に倣えばよい。
PWA
@2020-06-25
- reference: shadowwalker / next-pwa
最初に使った next-offline
は更新が遅く、またexperimentalな部分を利用していた等の理由から、next-pwa
に移行した。example から分かるように、非常にシンプルになった。
// next.config.js
const withPWA = require('next-pwa')
module.exports = withPWA({
pwa: {
dest: 'public'
}
})
TypeScirpt
@2020-06-30
Next.jsのTS化は非常に簡単で、最初のうちはNext.js Learn Typesciptなどに従えば良い。
touch tsconfig.json
# If you’re using Yarn
yarn add --dev typescript @types/react @types/node
あとは、Learn 等に従って、ts化していけば、何となく理解できる。また、tsconfig.json
でallowJs:true
にしておけば、もし仮に型が解らんものを含むjsファイルはそのままにしておいて、理解が進んでから完全にts化すればいいのでは。
- TSの理解を深める為に、読んだもの
npm-script
mizchi氏のブログ等を見てて、npm-script や EsModule 等を知った。ちょうど、sitemap.mxl
を造る必要があったので、利用することにした。
# pagesMap.json => sitmap.mxl
# pagesMap.json + history.json => rss
ただ、node v12 では ESModule は未だ experimental な機能で、package.json にも node --experimental-modules test.mjs
とする必要がある。しかし、v13からはフラグが要らないので、nodejsをアップデートした。
n --stable
# => 12.18.2
n --latest
# => 14.5.0
n latest
node -v
=> v14.4.0
vercel は nodejs の LTS しか対応しないので、package.json 中の npm-script は build 用 と generate script用で分ける必要があった。
"scripts": {
"dev": "next dev",
"build": "next build",
"local-build": "next build && node script/genRobots.mjs && node script/genPostsMap.mjs && node script/genSiteMap.mjs && node script/genRss.mjs && node script/genAtom.mjs",
"start": "next start",
},
mjsについて未だ良く解らん事、作るのが自分用のファイルジェネレーターであることもあって、コードが汚いので・・・↓
pages.json
@2020-06-30
postの情報を集約した postPages.json を作成した。ファイル更新履歴等はそのうち github から取得できるようにしたい。
- JSON.stringify が良く解らなかったので、読んだもの。
作りたいファイル構成
// {
// id: '20200526-next-portfolio',
// title: 'Qiita: Next.jsでポートフォリオサイトを作成した',
// create: '2020-05-26',
// update: '2020-06-05',
// tags: ['qiita', 'react', 'next.js', 'remark.js', 'vercel'],
// },
postsMap generator script
// script/genPagesMap.mjs
import path from 'path'
import fs from 'fs'
import matter from 'gray-matter'
const postsDirectory = path.join(process.cwd(), 'src/docs')
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)
const LowerCaseTags = matterResult.data.tags.map((tag) => (tag.toLowerCase()))
const title = matterResult.data.title
const create = matterResult.data.create
const update = matterResult.data.update || ''
const tags = LowerCaseTags || ''
return {
id,
title,
create,
update,
tags
}
})
const sortedPostsData = allPostsData.sort((a, b) => {
if (a.create < b.create) {
return 1
} else {
return -1
}
})
fs.writeFileSync(
path.join(process.cwd(), 'gen/postPages.json'),
JSON.stringify(sortedPostsData, undefined, 2),
'utf-8'
)
sitemap.xml
@2020-07-01
kuflash / react-router-sitemap や IlusionDev / nextjs-sitemap-generator等があるが、next.js なので、react-router を使ってないし、xml の構造は簡単そうだったので自作した。
- sitemap.xml を知るために読んだもの
sitemap.xml の基本構成
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>http://www.example.com/</loc>
<lastmod>2005-01-01</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
</urlset>
xmlはファイル頭に空白行が入ると、<?xml ?>
の宣言が無いと言ってエラーを吐く
sitemap.xml generator script
// script/genSiteMap.mjs
import path from 'path'
import fs from 'fs'
const base = 'https://oriverk.dev'
const fixed = [
{ url: base, update: '2020-06-26' },
{ url: '/posts', update: '2020-06-30' },
{ url: '/tags', update: '2020-06-26' }
]
const posts = JSON.parse(fs.readFileSync(
path.join(process.cwd(), 'gen/postPages.json'), 'utf8'
))
const sitemap = `<?xml version="1.0"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml"
xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0"
xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
${fixed.map((f) => {
return `<url>
<loc>${base === f.url ? base : base + f.url}</loc>
<lastmod>${f.update}</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
`}).join("")}
${posts.map((post) => { return `<url>
<loc>${base}/posts/${post.id}</loc>
<lastmod>${post.update || post.create}</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
`}).join("")}
</urlset>`
fs.writeFileSync(path.join(process.cwd(), "public/sitemap.xml"), sitemap)
RSS & Atom
@2020-07-01
RSS 2.0 と Atom 1.0 に対応する。
- 読んだもの
- RSS 2.0 フォーマット
<?xml version='1.0' encoding='UTF-8'?>
<rss version='2.0'>
<channel>
<title>hogehoge foobar</title>
<link>http://example.com/</link>
<description>aaaaaaaaaaaaaaaa</description>
<item>
<title>tegetege mikan</title>
<link>http://example.com/post3.html</link>
<description> this is description</description>
<pubDate>Wed, 11 Jun 2008 15:30:59 +0900</pubDate>
</item>
</channel>
</rss>
- Atom 1.0 フォーマット
<?xml version='1.0' encoding='UTF-8'?>
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='ja'>
<id>tag:example.comfeed/</id>
<title>example.com update info</title>
<updated>2020-06-11T15:30:59Z</updated>
<link rel='alternate' type='text/html' href='http://example.com/feed/' />
<link rel='self' type='application/atom+xml' href='http://example.com/feed/atom10.xml' />
<entry>
<id>http://example.com/post1.html#20080609205030</id>
<title>foobar</title>
<link rel='alternate' type='text/html' href='http://example.com/post1.html' />
<updated>2020-06-09T20:50:30Z</updated>
<summary>foofoooofooo</summary>
</entry>
</feed>
RSS と Atom のジェネレーターコードは、基本的に sitemap.xml と同じなので。
RSS 2.0 ジェネレーター
// script/genRss.mjs
import path from 'path'
import fs from 'fs'
const base = {
url: 'https://oriverk.dev',
title: "Kawano Yudai's site",
desc: "This site is for my portfolio and made with React, Next.js"
}
const posts = JSON.parse(fs.readFileSync(
path.join(process.cwd(), 'gen/postPages.json'), 'utf8'
))
const rss = `<?xml version='1.0'?>
<rss version='2.0'>
<channel>
<title>${base.title}</title>
<link>${base.url}</link>
<description>${base.desc}</description>
<language>ja</language>
<lastBuildDate>${new Date()}</lastBuildDate>/
${posts.map((post) => {
return `<item>
<title>${post.title}</title>
<link>${base.url}/posts/${post.id}</link>
<description>${post.tags.join(', ')}</description>
<pubDate>${post.create}</pubDate>
</item>
`}).join('')}
</channel>
</rss>`
fs.writeFileSync(path.join(process.cwd(),'public/rss.xml'), rss)
Atom 1.0 ジェネレーター
Atom の ユニークid をどうしようかと考えましたが、適当に。
// script/genRss.mjs
import path from 'path'
import fs from 'fs-extra'
const base = {
url: 'https://oriverk.dev',
title: "Kawano Yudai's site",
desc: "This site is for my portfolio and made with React, Next.js"
}
const posts = JSON.parse(fs.readFileSync(
path.join(process.cwd(), 'gen/postPages.json'), 'utf8'
))
const atom = `<?xml version='1.0'?>
<feed xmlns='http://www.w3.org/2005/Atom' xml:lang='ja'>
<id>${base.url}</id>
<title>${base.title}</title>
<updated>${new Date()}</updated>
<link rel='alternate' type='text/html' href='${base.url}' />
<link rel='self' type='application/atom+xml' href='${base.url + '/atom.xml'}' />
${posts.map((post) => {
return `<entry>
<id>${post.id}</id>
<title>${post.title}</title>
<link rel='alternate' type='text/html' href='${base.url + '/posts/' + post.id}' />
<updated>${post.update || post.create}</updated>
<summary>${post.tags.join(', ')}</summary>
</entry>`}).join('')}
</feed>`
fs.writeFileSync(path.join(process.cwd(), 'public/atom.xml'), atom)
Algolia search
投稿記事の検索に Algolia を利用した。postsMap.json
をデータとして投入した。
現在のデータ投入は手動で行っているが、後で postsMap.json
生成時に差分があればapi
で投入できるようにしたい。
To do
- CSSの統一
- AMP対応( 参照:Next.js next/amp
- コードブロックの言語またはファイル名の出力
- syntax-highlightの改善
- postページの目次機能
- og:image 動的生成コード