Next.js 15.1のafter APIを読み解いていく

Next.js 15.1のafter APIを読み解いていく

こんにちは!TechJourneyを運営しているkoimaiです!

突然ですが、Next.js 15.1で安定版としてリリースされたafter APIをみなさんは使ってみましたか?

afterを使用することで、ユーザーへのレスポンスの遅延や非同期処理の中断といった問題を解決することができます。

この記事では、after APIの基本から実践的な実装例まで、順を追ってまとめていきます。

after APIについて

Next.js 15.0のリリースでunstable_afterとして実験的に導入され、v15.1で正式にafter APIとして安定版がリリースされました。

このAPIは、レスポンスの完了後やプリレンダリングの完了後に実行される処理を簡単にスケジュールできる機能です。

例えば、フォームの送信処理を考えてみましょう。ユーザーがフォームを送信すると、データベースへの保存とログの記録という2つの処理が必要になります。

従来は、これらの処理が完了するまでユーザーを待たせるか、非同期処理を適切に管理する必要がありました。after APIを使えば、データベースへの保存が完了した時点でユーザーにレスポンスを返し、ログの記録はその後に実行することができます。

このAPIはServer Components(generateMetadataを含む)、Server Actions、Route Handlers、Middlewareのいずれでも使用可能です。特に、レスポンスをブロックすべきでないタスクやサイドエフェクトの実行に効果を発揮します。

それでは、after APIの基本的な機能から詳しく見ていきましょう。

after APIの基本的な機能

after APIの最も基本的な機能は、レスポンスの完了後に実行される処理をスケジュールすることです。以下のコードは、最もシンプルなafter APIの使用例です:

import { after } from 'next/server'
// Custom logging function
import { log } from '@/app/utils'
 
export default function Layout({ children }: { children: React.ReactNode }) {
  after(() => {
    // レイアウトがレンダリングされ、ユーザーに送信された後に実行する。
    log()
  })
  return <>{children}</>
}

ここで重要なポイントは、after関数に渡されたコールバックがレイアウトのレンダリング完了後に実行されることです。これは以下のような特徴を持っています:

  • レスポンスの完了を待たずにコールバックが実行されるため、ユーザーへのレスポンス時間に影響を与えません
  • 静的ページで使用した場合は、ビルド時またはページが際検証される時に実行されます
  • エラーが発生した場合や、notFound、redirectが呼び出された場合でも実行されます

また、after関数は非同期処理にも対応しています:

import { after } from 'next/server'
import { sendAnalytics } from '@/lib/analytics'

export async function POST(request: Request) {
  const data = await request.json()

  after(async () => {
    await sendAnalytics(data)
  })

  return Response.json({ status: 'success' })
}

この例では、POSTリクエストのレスポンスを返した後に、非同期のアナリティクス送信処理が実行されます。これにより、アナリティクスの処理時間がAPIのレスポンスタイムに影響を与えることはありません。

after APIの実行時間は、プラットフォームのデフォルトまたは設定されたルートの最大実行時間に従います。必要に応じて、maxDurationルートセグメント設定でタイムアウト制限を調整することも可能です。

after APIの特徴

after APIの特徴は、動的なAPIではないということです。つまり、afterを呼び出してもルートが動的になることはありません。

これは、静的なページで使用した場合、コールバックがビルド時やページが再検証される時に実行されることを意味します。

次に、afterの中でafterを呼び出すネスト構造がサポートされています。これにより、ユーティリティ関数として機能を拡張することができます:

import { after } from 'next/server'

function log(message: string) {
  console.log(`[${new Date().toISOString()}] ${message}`);
}

const metrics = {
  recordAction: (data: Record<string, unknown>) => {
    console.log('Action recorded:', JSON.stringify(data));
  }
};

function withLogging(callback: () => void) {
  after(() => {
    log('処理を開始します')
    callback()
    log('処理が完了しました')
  })
}

export async function POST(request: Request) {
  const data = await request.json()

  withLogging(() => {
    metrics.recordAction(data)
  })

  return Response.json({ status: 'success' })
}

実際に、curlコマンドでAPIリクエストしてみました。

curl -X POST http://localhost:3000/api/test-after -H "Content-Type: application/json" -d '{"action": "test"}'

サーバーのコンソールログはこのようになりました。外側のafterがログを記録し、内側のafter(withLogging関数内)がメトリクスを記録しています。

 POST /api/test-after 200 in 654ms
[2024-12-23T11:21:20.491Z] 処理を開始します
Action recorded: {"action":"test"}
[2024-12-23T11:21:20.492Z] 処理が完了しました

また、Server ActionsやRoute Handlersでは、after内でcookiesやheadersなどのリクエストAPIを使用できます:

import { after } from 'next/server'
import { cookies, headers } from 'next/headers'

export async function POST(request: Request) {
  after(async () => {
    const userAgent = headers().get('user-agent')
    const sessionId = cookies().get('session-id')?.value

    await saveAccessLog({ userAgent, sessionId })
  })

  return Response.json({ status: 'success' })
}

ただし、Server Componentsではafter内でこれらのリクエストAPIを使用することはできません。これは、Next.jsがPartial Prerenderingをサポートするために、ツリーのどの部分がリクエストAPIにアクセスするかを把握する必要があるためです。

さらに、React cacheを使用することで、after内で呼び出される関数の重複実行を防ぐことができます。これは、同じ処理が複数回実行されることを避けたい場合に特に有用です。

after APIを使用するときの注意点

after APIを使用する際には、いくつかの重要な注意点があります。これらを理解することで、より効果的にAPIを活用できます。

まず、afterコールバックの実行タイミングについて理解しておく必要があります。afterは、エラーが発生した場合やnotFound、redirectが呼び出された場合でも実行されます。つまり、以下のようなコードでも、ログは必ず記録されます:

import { after } from 'next/server'
import { redirect } from 'next/navigation'
import { log } from '@/lib/logging'

export async function POST(request: Request) {
  const { userId } = await request.json()

  after(() => {
    log(`アクセスユーザー: ${userId}`)  // redirectの後でも実行される
  })

  if (!userId) {
    redirect('/login')
  }

  return Response.json({ status: 'success' })
}

次に、実行時間の制限に注意が必要です。afterの実行時間は、プラットフォームが定める制限に従います。長時間の処理が必要な場合は、以下のような対策を検討する必要があります:

  • 処理を小さな単位に分割する
  • バックグラウンドジョブキーを使用する
  • maxDurationルートセグメント設定で制限時間を調整する

また、Server Componentsでの制約も重要です:

export default function PageComponent() {
  // ❌ Server Componentsではafter内でリクエストAPIは使用できない
  after(() => {
    const headerValue = headers().get('x-custom-header')  // エラーになります
    log(headerValue)
  })

  // ✅ コンポーネントのレンダリング中であれば使用可能
  const headerValue = headers().get('x-custom-header')
  after(() => {
    log(headerValue)  // 変数として渡すことは可能
  })

  return <div>ページコンテンツ</div>
}

最後に、afterはテスト時の考慮も必要です。afterコールバックは非同期で実行されるため、テストでは適切な待機処理が必要になる場合があります。

after APIとフォームを組み合わせた実装例

フォーム送信は、after APIの利点を最も活かせるユースケースの1つです。ユーザーからのフォーム送信を受け付けた際、通常はデータの保存とログの記録という2つの処理が必要になります。

after APIを使用することで、データ保存後すぐにユーザーにレスポンスを返し、ログの記録は非同期で行うことができます。

実装したコード

まず、Server Actionの実装を見ていきましょう:

"use server";

import { after } from "next/server";

interface FormState {
  message: string;
}

export async function submitForm(prevState: FormState, formData: FormData): Promise<FormState> {
  const name = formData.get("name");
  const email = formData.get("email");

  // メインの処理(例:データベースへの保存)
  console.log(`Saving user: ${name}, ${email}`);

  // レスポンス後に実行される処理
  after(async () => {
    await new Promise((resolve) => setTimeout(resolve, 5000));
    console.log(`Logging: Form submitted for ${name} at ${new Date().toISOString()}`);
  });

  return { message: "Form submitted successfully" };
}

このServer Actionでは、フォームデータの保存処理の後、after APIを使用して5秒後にログを記録する処理を実行しています。この5秒の遅延は実際のログ記録処理を模擬したものです。

次に、クライアントコンポーネントの実装です:

"use client";

import { useActionState, useRef } from "react";
import { submitForm } from "./actions";

interface FormState {
  message: string;
}

const initialState: FormState = {
  message: "",
};

export default function Page() {
  const [state, formAction, isPending] = useActionState(submitForm, initialState);
  const formRef = useRef<HTMLFormElement>(null);

  return (
    <form
      ref={formRef}
      action={async (formData: FormData) => {
        await formAction(formData);
        formRef.current?.reset();
      }}
    >
      <input type="text" name="name" placeholder="Name" required />
      <input type="email" name="email" placeholder="Email" required />
      <button type="submit" disabled={isPending}>
        {isPending ? "Submitting..." : "Submit"}
      </button>
      {state?.message && <p>{state.message}</p>}
    </form>
  );
}

フォームを送信したときのサーバー結果例:

Saving user: koimai, koimai@gmail.com
 POST / 200 in 45ms
Logging: Form submitted for koimai at 2024-12-23T10:49:21.957Z

このコンポーネントでは、useActionStateフックを使用してフォームの状態を管理し、送信中は送信ボタンを無効化しています。フォーム送信後は自動的にフォームをリセットする処理も実装されています。

after APIを使用したメリット

この実装には、以下のような明確なメリットがあります:

  • ユーザー体験の向上
    • メインの処理(データ保存)が完了次第、すぐにユーザーにフィードバックを返すことができます
    • ログ記録の5秒間の処理をユーザーが持つ必要がありません
  • コードの整理
    • メインの処理とサイドエフェクト(ログ記録)が明確に分離されています
    • afterブロック内のコードは、メインのフローに影響を与えません
  • エラーハンドリングの簡素化
    • ログ記録処理で発生したエラーが、ユーザーへのレスポンスに影響を与えません
    • メインの処理に集中したエラーハンドリングが可能です

このように、after APIを使用することで、ユーザー体験とコードの品質の両方を向上させることができます。

after APIを使用しない場合の実装例

after APIが登場する以前は、レスポンス後の処理を実装するためにいくつかのアプローチが存在していました。

それぞれのアプローチを見ていくことで、after APIがどのような課題を解決しているのかを理解することができます。

ログ記録の非同期関数を使用する場合

最も単純なアプローチは、非同期関数を使用してログを記録する方法です:

"use server";

interface FormState {
  message: string;
}

async function logSubmission(name: string, email: string) {
  // 実際にはデータベースへの書き込みなどの時間のかかる処理
  console.log(`Logging: Form submitted for ${name} (${email}) at ${new Date().toISOString()}`);
  await new Promise((resolve) => setTimeout(resolve, 5000));
}

export async function submitForm(prevState: FormState, formData: FormData): Promise<FormState> {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;

  // メインの処理(例:データベースへの保存)
  console.log(`Saving user: ${name}, ${email}`);

  // ログ記録(レスポンスが遅れる)
  await logSubmission(name, email);

  return { message: "Form submitted successfully" };
}

フォーム送信をしたときのサーバー結果例:

Saving user: koimai, koimai@gmail.com
Logging: Form submitted for koimai (koimai@gmail.com) at 2024-12-23T10:51:15.718Z
 POST / 200 in 5067ms

この実装の問題点は、logSubmission関数の完了を待ってからレスポンスを返すため、ユーザーの待ち時間が長くなることです。5秒の遅延が発生するため、ユーザー体験が著しく低下します。

ログ記録の同期関数を使用する場合

同期関数を使用する場合は、awaitを使用せずにログ記録処理を実行する方法です:

"use server";

interface FormState {
  message: string;
}

async function logSubmission(name: string, email: string) {
  // 実際にはデータベースへの書き込みなどの時間のかかる処理
  console.log(`Logging: Form submitted for ${name} (${email}) at ${new Date().toISOString()}`);
  await new Promise((resolve) => setTimeout(resolve, 5000));
}

export async function submitForm(prevState: FormState, formData: FormData): Promise<FormState> {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;

  // メインの処理(例:データベースへの保存)
  console.log(`Saving user: ${name}, ${email}`);

  // ログ記録(実行中に途切れる可能性がある)
  logSubmission(name, email);

  return { message: "Form submitted successfully" };
}

フォーム送信したときのサーバー結果例:

Saving user: koimai, koimai@gmail.com
Logging: Form submitted for koimai (koimai@gmail.com) at 2024-12-23T10:59:48.969Z
 POST / 200 in 46ms

この実装では、logSubmission関数をawaitせずに呼び出しています。これにより、非同期関数にしたことによって発生したユーザーの待ち時間は解決できましたが、ログ記録処理の完了が保証されません。

試しに、logSubmission関数のログ出力の前にもタイムアウトの行を実行すると、ログとレスポンス結果の出力が逆になりました。

Saving user: koimai, koimai@gmail.com
 POST / 200 in 53ms
Logging: Form submitted for koimai (koimai@gmail.com) at 2024-12-23T11:00:31.526Z

vercel/functionsのwaitUntil関数を使用する場合

Vercelプラットフォームを使用している場合、waitUntil関数を使用することで、レスポンスを返した後も処理を継続することができます:

"use server";

import { waitUntil } from '@vercel/functions';

interface FormState {
  message: string;
}

async function logSubmission(name: string, email: string) {
  await new Promise((resolve) => setTimeout(resolve, 5000));
  console.log(`Logging: Form submitted for ${name} (${email}) at ${new Date().toISOString()}`);
}

export async function submitForm(prevState: FormState, formData: FormData): Promise<FormState> {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;

  // メインの処理(例:データベースへの保存)
  console.log(`Saving user: ${name}, ${email}`);

  // ログ記録をwaitUntil()でスケジュール
  waitUntil(logSubmission(name, email));

  return { message: "Form submitted successfully" };
}

フォームを送信をしたときのサーバー結果例:

Saving user: koimai, koimai@gmail.com
 POST / 200 in 81ms
Logging: Form submitted for koimai (koimai@gmail.com) at 2024-12-23T10:44:12.304Z

waitUntil関数は、after APIに似た機能を提供しますが、いくつかの制限があります:

  • Vercelプラットフォーム固有の機能であり、他の環境では使用できません
  • リクエストのライフサイクル中に実行されるため、実行時間に制限があります
  • エラーハンドリングがafter APIと比べて複雑になってしまいます

これらの実装例から、after APIがもたらす利点が明確になったと思います。

after APIは、プラットフォームに依存せず、より柔軟で信頼性の高い方法でレスポンス後の処理を実装することを可能にします。

キャッシュ管理はレンダリングのあとで

ブログ記事のような動的コンテンツを扱うアプリケーションでは、キャッシュの管理が重要です。

ここでは、「謎解きはディナーのあとで」ではなく、「キャッシュ管理はレンダリングのあとで」していきたいと思います。

特に、記事の更新後には関連するページのキャッシュを適切なタイミングで再検証する必要があります。after APIは、このキャッシュ管理を効率的に行う新しい方法を提供します。

実装例として、ブログ記事の編集機能を見ていきましょう。この機能では、記事の更新後に以下のキャッシュを再検証する必要があります:

  • 更新された記事のページ
  • ブログ一覧ページ
  • 関連するタグページ

まず、記事更新のServer Actionを見てみましょう:

"use server";

import { after } from "next/server";
import { revalidatePath } from "next/cache";

interface BlogPost {
  id: string;
  title: string;
  content: string;
  tags: string[];
}

async function updatePostInDatabase(post: BlogPost) {
  // 実際のデータベース更新ロジック
  await new Promise((resolve) => setTimeout(resolve, 1000)); // データベース操作のシミュレーション
  console.log(`Database updated for post ${post.id}`);
}

export async function updateBlogPost(post: BlogPost): Promise<{ success: boolean; message: string }> {
  try {
    // メインの処理:データベースの更新
    await updatePostInDatabase(post);

    // 即座にレスポンスを返す
    const response = { success: true, message: "記事が更新されました" };

    // キャッシュの更新をafter内でスケジュール
    after(async () => {
      try {
        // 更新された記事ページのキャッシュを再検証
        revalidatePath(`/blog/${post.id}`);

        // ブログ一覧ページのキャッシュを再検証
        revalidatePath("/blog");

        // 各タグページのキャッシュを再検証
        post.tags.forEach((tag) => {
          revalidatePath(`/tags/${tag}`, "page");
        });

        console.log(`Blog post ${post.id} cache revalidated successfully`);
      } catch (error) {
        console.error("Error in cache revalidation:", error);
      }
    });

    return response;
  } catch (error) {
    console.error("Error updating blog post:", error);
    return { success: false, message: "記事の更新中にエラーが発生しました" };
  }
}

このコードの特徴は以下の点です:

  • レスポンスの即時返却
    • データベース更新が完了したら、すぐにユーザーにレスポンスを返します
    • キャッシュの再検証を待つ必要がありません
  • 段階的なキャッシュ更新
    • 記事ページ、一覧ページ、タグページと、複数のキャッシュを順次更新します
    • エラーが発生しても、ユーザーへのレスポンスには影響しません
  • エラーハンドリングの分離
    • メインの処理とキャッシュ更新のエラーハンドリングを別々に管理できます
    • キャッシュ更新の失敗を監視サービスに通知するなどの拡張も容易です

この実装のもう一つの利点は、将来的なキャッシュ更新ロジックの変更に強いことです。

例えば、新しいページタイプが追加された場合や、キャッシュ戦略を変更する必要が生じた場合でも、メインの処理に影響を与えることなく、after内のコードを修正できます。

このように、after APIを使用することで、「レンダリング」と「キャッシュ管理」を明確に分離し、より保守性の高いコードを実現できます。

ただ、キャッシュ更新ってそんなに時間のかかる処理ではなくインパクトは小さいと思うので、メインの処理を工夫することが優先事項ではありそうです。

まとめ

この記事では、Next.js 15.1で安定版としてリリースされたafter APIについて詳しく見てきました。after APIの主な特徴と利点は以下のようにまとめられます:

  • レスポンスをブロックしない処理の実現
    • メインの処理完了後、すぐにユーザーにレスポンスを返すことができます
    • ログ記録やアナリティクス送信などの二次的な処理を効率的に実行できます
  • 柔軟な実装オプション
    • Server Components、Server Actions、Route Handlers、Middlewareなど、様々な場合で使用できます
    • ネスト構造をサポートし、機能の拡張が容易です
  • 実践的なユースケース
    • フォーム送信後のログ記録
    • ブログ記事更新後のキャッシュ再検証
    • アナリティクスデータの送信

また、従来の実装方法(非同期関数、同期関数、waitUntil関数)と比較することで、after APIがもたらす改善点も明確になりました。

after APIは、Next.jsアプリケーションにおけるパフォーマンスとユーザー体験の向上に大きく貢献する機能です。レスポンス時間の短縮とバックグラウンド処理の適切な管理を実現することで、より高品質なアプリケーション開発が可能になります。

最後まで読んでいただき、ありがとうございます!!