🔖

【Next.js】useEffectを使ってリダイレクトすると一瞬画面がチラつく問題【middleware】

2023/11/18に公開

この記事について

Next.jsにおいて、useEffectを使ってリダイレクトすると一瞬画面がチラつく問題を、middlewareという機能を使って解決する方法をまとめた記事になります。

今回はToDoアプリのように、1画面でCRUD処理が可能だったり、ページ内のデータが頻繁に更新されるようなものを想定しているため、getServerSidePropsといったサーバー側でリダイレクト処理を行う方法については触れていません。

やりたいこと

ログインの有無によって、ユーザーがアクセスできるページを制限したい。

例えば、以下のようなページで構成されているToDoアプリがあるとして、

  • /
  • /signup
  • /signin
  • /todo

ログインしていない場合
/todoにアクセスした時は、/signinにリダイレクトする。

ログインしている場合
/signup及び/signinにアクセスした時は、/todoにリダイレクトする。

環境

package.json
  "dependencies": {
    "next": "13.4.12",
    "react": "18.2.0",
    "react-dom": "18.2.0"
  },
  • Next.jsはPages Routerを使っています。
  • ページ生成はデフォルトのStaticに、client-side data fetchingでDBから最新のデータを都度取得する構成にしています。

useEffectを使う場合

実装例

仕組みはおおまかに以下のとおりです。

  • グローバルでユーザーのログイン状態を管理
  • DBから取得したToDoリストを表示 /todo

ログイン中のリダイレクトについても同様なので割愛します。

auth.js
import { useState, useEffect, createContext } from 'react'
import { getCurrentUser } from 'src/lib/api/auth'

// グローバルで扱うためにエクスポートする
export const AuthContext = createContext({})

//_app.jsにエクスポートして、全体の親にする
export const AuthProvider = ({ children }) => {
  const [isSignedIn, setIsSignedIn] = useState(undefined)
  const value = { isSignedIn, setIsSignedIn }

  // 確認できた場合はそのユーザーの情報を取得
  const handleGetCurrentUser = async () => {
    try {
      // 認証済みのユーザーがいるかどうかチェックする処理
      const res = await getCurrentUser()
      
      // 確認結果をもとにisSignedInを更新
      // 今回は確認結果として「isLogin」という値が渡されると想定
      if (res?.data.isLogin === true) {
        setIsSignedIn(true)
      } else {
        setIsSignedIn(false)
      }
    } catch (err) {
      console.log(err)
    }
  }

  // ログイン状態を監視
  useEffect(() => {
    handleGetCurrentUser()
  }, [setCurrentUser])

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
todo.jsx
import { useState, useEffect, useContext } from 'react'
import { useRouter } from 'next/router'
import { getTodoList } from 'src/lib/api/todo'
import { AuthContext } from 'src/contexts/auth'

export const TodoListPage = () => {
  const router = useRouter()
  const [todolist, setTodoList] = useState([])
  const { isSignedIn } = useContext(AuthContext)

  const fetch = async () => {
    // ログインしていない時は/signinページにリダイレクト
    if (!isSignedIn) {
      router.replace('/signin')
      return
    }

    // Todoを取得する処理
    try {
      const res = await getTodoList()
      setTodoList(res.data)
    } catch (err) {
      console.log(err)
    }
  }

  useEffect(() => {
    fetch()
  }, [])

  return (
    <>
      <h1>Todoリスト</h1>
      {todolist.map((item) => (
        <div key={item.id}>
          <p>{item.name}</p>
        </div>
      ))}
    </>
  )
}

問題点

1. アクセスしてほしくないページが一瞬映る
Next.jsは、CSR(SPA)のデメリットであるファーストビューを表示するまでに時間がかかる点を克服するために、基本的に全てのページをPre-renderingします。
つまり、ビルド時に事前生成したHTMLが先に表示され、その後にJSコードが実行されます。

useEffectを使った上記のコードの場合、ログインしていない状態で/todoにアクセスしようとすると、実際にTodoリストの中身は表示されないものの、見出しの「Todoリスト」だけは先に表示され、その後/signinにリダイレクトされます。

実装自体は簡単ですが、本来アクセスさせたくないページが一瞬表示され、その後リダイレクトされるということで、UX的に微妙です。

2. 各ページにログインチェックを実装する必要がある
アクセスコントロールを実施したいページの数だけコードを記述しなければならないため、ページの数が多くなればなるほど漏れる可能性が増え、保守性に懸念があります。

middlewareを使う場合

使用方法

こちらの記事が詳細でわかりやすく参考になります。

https://zenn.dev/hayato94087/articles/ec16174696a375#1.-はじめに

実装例

今回はRuby on RailsのDevise Token Authというトークンベースの認証ライブラリを使って実装しました。ヘッダーにセットしているaccess-token,client,uidはこのライブラリで認証情報を扱うために必要な値であり、middelewareとは直接関係ありません。
この部分については使用している認証ライブラリ等に合わせてカスタマイズしてください。

middleware.js
import { NextResponse } from 'next/server'

export async function middleware(request) {
  // APIを叩くにあたりCookieから必要な情報を取得
  const accessToken = request.cookies.get('_access_token')?.value
  const client = request.cookies.get('_client')?.value
  const uid = request.cookies.get('_uid')?.value

  const res = await fetch('http://localhost:3000/auth/sessions', {
   // axiosだとwithCredentials: trueとなるが、ここでは使えないので注意
    credentials: 'include',
    headers: {
      'access-token': accessToken,
      client: client,
      uid: uid
    }
  })

  const data = await res.json()

  // 未ログイン時のリダイレクト対象ページ
  const redirectIfNotLoggedInPaths = ['/todo']

  // ログイン時のリダイレクト対象ページ
  const redirectIfLoggedInPaths = ['/signup', '/signin']

  // 未ログイン時は/signinにリダイレクト
  if (
    data.is_login === false &&
    redirectIfNotLoggedInPaths.some((path) => request.nextUrl.pathname.startsWith(path))
  ) {
    return NextResponse.redirect(new URL('/signin', request.url))
  }

  // ログイン時は/todoにリダイレクト
  if (
    data.is_login === true &&
    redirectIfLoggedInPaths.some((path) => request.nextUrl.pathname.startsWith(path))
  ) {
    return NextResponse.redirect(new URL('/todo', request.url))
  }

  return NextResponse.next()
}


メリット

  • 全てのレンダリングより前に実行されるので、アクセスコントロールが確実になる。
  • リダイレクト対象のページが1ファイルにまとまっており、管理しやすい。

注意点

axiosといったfetchライブラリやCookieを操作するライブラリ等、ブラウザで動くライブラリは使用できません。

その代わり、cookies()というmiddleware独自のメソッドが用意されているので、こちらを使いました。

その他の方法

今回はToDoアプリを想定していたため、getServerSidePropsといったサーバー側でリダイレクト処理を行う方法については触れていませんでしたが、その他の方法も含めて検討したい方は、以下の記事が参考になります。

https://zenn.dev/uttk/articles/4649e49f1e6628

https://zenn.dev/k_log24/articles/f6486b8a2e3e63

まとめ

初めてアクセスコントロールを考えた時、useEffectを使ったリダイレクトは、実装が簡単で1番最初に思い付く方法だと思います。

Next.jsのレンダリング方法や一般的にページが表示されるまでの流れ(ハイドレーション等)を理解していなかった筆者は、そもそもなぜリダイレクトするように記述しているのにページが一瞬チラつくのか、原因に気づくのに予想以上に時間がかかってしまいました。(そしてmiddlewareに辿り着くまでにさらに時間がかかりました。)

middlewareは簡単かつ確実にアクセスコントロールを実装できる方法ですので、同じようなことでお悩みの方に手早くこの情報が届くと嬉しいです。

また、記事に間違いなどがあれば、コメントで教えて頂けると幸いです。

その他の参考リンク

https://shinseidaiki.hatenablog.com/entry/2021/11/22/043433

https://nextjs-server-side-access-control.vercel.app/1

https://magicode.io/Sumiren/articles/9a182ba35cc0410fbe6917d84eff2488

Discussion