Skip to content

Next.js

nextjsreact-hook-formzoddocker

Server Actions

Result Type Best Practice

If you throw an Error in a server action, it will be caught by ErrorBoundary and error.tsx will be called. However, this is only an unexpected error; in most cases of the real world, errors will be expected. In that case, returning an object such as a message is common instead of throwing an error. Also, the Result type is friendly for useActionState.

ts
import type { typeToFlattenedError } from "zod";

type SuccessResult<T> = {
  success: true;
  data: T;
  message?: string;
};

type FailureResult<T, U> = {
  success: false;
  message?: string;
  data?: U;
  zodErrors?: typeToFlattenedError<T>["fieldErrors"];
};

/**
 * @typeParam T - data to be returned if successful
 * @typeParam U - validation error by zod
 * @typeParam P - data to be returned if failed
 */
export type Result<T = void, U = Record<string, unknown>, P = never> =
  | SuccessResult<T>
  | FailureResult<U, P>;
ts
"use server";

import type { Session } from "next-auth";
import { auth } from "../_clients/nextAuth";
import type { Result } from "./types";

export async function getSessionOrReject(): Promise<Result<Session, void>> {
  try {
    const session = await auth();

    if (!session?.user?.id) {
      return {
        success: false,
        message: "no session token",
      };
    }

    return {
      success: true,
      data: session,
    };
  } catch {
    return {
      success: false,
      message: "no session token",
    };
  }
}

WARNING

The action of Form requires Promise<void> so when using Form directly, need to avoid using the Result type.

ts
<form action={submitAction} />

Middleware

Authentication and Authorization Best Practice

It is common to use middleware to check a user's role, etc., and restrict access to pages. This template uses JSON Web Token as session tokens to check the permissions. Learn more here.

ts
import NextAuth from "next-auth";
import { NextResponse } from "next/server";
import { config as authConfig } from "./app/_clients/nextAuthConfig";

const { auth } = NextAuth(authConfig);

export const config = { matcher: ["/me(.*)"] };

export default auth(async function middleware(req) {
  if (req.auth?.user.role === "USER") {
    return NextResponse.next();
  }

  return NextResponse.rewrite(new URL("/signin", req.url));
});

Testing

WARNING

This feature is experimental

Next.js provides utilities to help unit test middleware files.

conducting tests based on the following aspects:

  • Is the routing working correctly? (without executing the middleware logic)
    • unstable_doesMiddlewareMatch
  • Is the middleware logic functioning as expected?
    • isRewrite and getRewrittenUrl
See Full Code
ts
import { beforeEach } from "node:test";
import type { NextAuthResult } from "next-auth";
import type { AppRouteHandlerFn } from "next/dist/server/route-modules/app-route/module";
import {
  getRewrittenUrl,
  isRewrite,
  unstable_doesMiddlewareMatch,
} from "next/experimental/testing/server.js";
import { NextRequest, type NextResponse } from "next/server";
import { describe, expect, test, vi } from "vitest";
import nextConfig from "../next.config";
import middleware, { config } from "./middleware";

type NextAuthRequest = Parameters<Parameters<NextAuthResult["auth"]>[0]>[0];

describe("middleware", () => {
  beforeEach(() => {
    vi.mock("next-auth", async (actual) => ({
      ...(await actual<typeof import("next-auth")>()),
      default: () => ({
        auth: (
          fn: (
            req: NextAuthRequest,
            ctx: AppRouteHandlerFn,
          ) => Promise<NextResponse>,
        ) => fn,
      }),
    }));
  });

  test("should execute middleware when paths are specified by config", () => {
    const fixtures: [string, boolean][] = [
      ["/", false],
      ["/me", true],
    ];

    for (const [url, expected] of fixtures) {
      expect(
        unstable_doesMiddlewareMatch({
          config,
          nextConfig,
          url,
        }),
      ).toEqual(expected);
    }
  });

  test("should route /signin to when fallback", async () => {
    const req = new NextRequest("http://localhost:3000");
    const res = (await middleware(req, {})) as NextResponse;

    expect(isRewrite(res)).toEqual(true);
    expect(getRewrittenUrl(res)).toEqual("http://localhost:3000/signin");
  });

  test("should accept only users having role of user", async () => {
    const req = {
      auth: {
        user: {
          role: "USER",
        },
        expires: "expires",
      },
    } as NextAuthRequest;

    const res = (await middleware(req, {})) as NextResponse;

    expect(isRewrite(res)).toEqual(false);
    expect(getRewrittenUrl(res)).toEqual(null);
  });
});

Building with Docker Optional

This template provides a Dockerfile for your application.

See Full Code
FROM node:22.14.0-slim AS base

ARG DATABASE_USER=''
ARG DATABASE_PASSWORD=''
ARG DATABASE_DB=''
ARG DATABASE_HOST=''
ARG DATABASE_PORT=''
ARG DATABASE_SCHEMA=''
ARG GOOGLE_CLIENT_ID=''
ARG GOOGLE_CLIENT_SECRET=''
ARG NEXT_PUBLIC_SITE_URL=''
ARG NEXTAUTH_SECRET=''
# start: otel #
ARG TRACE_EXPORTER_URL=''
# end: otel #
# start: stripe #
ARG STRIPE_PRICE_ID=''
ARG STRIPE_SECRET_KEY=''
ARG STRIPE_WEBHOOK_SECRET=''
# end: stripe #

ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV DATABASE_USER=$DATABASE_USER
ENV DATABASE_PASSWORD=$DATABASE_PASSWORD
ENV DATABASE_DB=$DATABASE_DB
ENV DATABASE_HOST=$DATABASE_HOST
ENV DATABASE_PORT=$DATABASE_PORT
ENV DATABASE_SCHEMA=$DATABASE_SCHEMA
ENV GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID
ENV GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
ENV NEXTAUTH_URL=$NEXT_PUBLIC_SITE_URL
# start: otel #
ENV TRACE_EXPORTER_URL=$TRACE_EXPORTER_URL
# end: otel #
# start: stripe #
ENV STRIPE_PRICE_ID=$STRIPE_PRICE_ID
ENV STRIPE_SECRET_KEY=$STRIPE_SECRET_KEY
ENV STRIPE_WEBHOOK_SECRET=$STRIPE_WEBHOOK_SECRET
# end: stripe #

COPY . /app
WORKDIR /app

RUN npm run setup
# for prisma
RUN apt-get update -y && apt-get install -y openssl

FROM base AS prod-deps

RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm i --prod --frozen-lockfile
RUN pnpm prisma generate --generator client

FROM base AS build

RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm i --frozen-lockfile
RUN pnpm build

FROM base AS app

COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build /app/.next /app/.next

EXPOSE 3000
CMD ["pnpm", "start"]