Skip to content

Routing

This template provides 3 pages(+ intercepting) based on Next.js App Router.

app
 ├── (private)
 │   ├── layout.tsx
 │   └── me
 │       └── page.tsx
 ├── (public)
 │   ├── layout.tsx
 │   ├── page.tsx
 │   └── signin
 │       └── page.tsx
 ├── @dialog
 │   ├── (.)create
 │   │   ├── Content.tsx
 │   │   ├── default.tsx
 │   │   └── page.tsx
 │   ├── _components
 │   ├── default.tsx
 │   └── page.tsx
 ├── layout.tsx
 └── not-found.tsx

Top (/)

What can you learn from this page?

  • Server Components
  • Server Components + Form + Server Actions
  • How to use Suspense
  • How to retrieve the session with nextAuth on Server Components

Affected Layouts

  • app/layout.tsx
  • app/(public)/layout.tsx
  • app/(public)/page.tsx
See Full Code
tsx
import Image from "next/image";
import Link from "next/link";
import { Suspense } from "react";
import { deleteAll } from "../_actions/items";
import { auth } from "../_clients/nextAuth";
import { prisma } from "../_clients/prisma";
import { Button } from "../_components/Button";
import { format } from "../_utils/date";

export default async function Page() {
  return (
    <div className="space-y-5">
      <Suspense fallback={<p>loading ...</p>}>
        <Status />
      </Suspense>
      <Suspense fallback={<p>loading ...</p>}>
        <List />
      </Suspense>
    </div>
  );
}

async function Status() {
  const session = await auth();

  return (
    <div className="flex justify-between gap-3 flex-col md:flex-row md:items-center">
      <p className="text-gray-300" aria-label="User status">
        {session?.user
          ? `you are signed in as ${session.user.name} 😄`
          : "you are not signed in 🥲"}
      </p>
      {session?.user && (
        <div className="flex items-center gap-4">
          <Link href="/create" scroll={false}>
            <Button className="bg-blue-600">Add an item</Button>
          </Link>
          <form action={deleteAll}>
            <Button type="submit" className="bg-orange-800  text-gray-100">
              Delete my items
            </Button>
          </form>
        </div>
      )}
    </div>
  );
}

async function List() {
  const data = await prisma.item.findMany({
    include: {
      user: true,
    },
    orderBy: {
      createdAt: "desc",
    },
  });

  return (
    <ul className="space-y-4" aria-label="items">
      {data.map(({ id, content, createdAt, user }) => (
        <li
          key={id}
          className="border border-gray-600 p-4 flex justify-between items-start rounded-md"
        >
          <div className="flex justify-center gap-4 items-center">
            {user.image && (
              <Image
                alt={user.name ?? "no name"}
                src={user.image}
                width={56}
                height={56}
                className="rounded-full border-2 border-gray-300"
                priority
              />
            )}
            <h2
              className="font-semibold md:text-xl break-all"
              title="memo title"
            >
              {content}
            </h2>
          </div>
          <span className="text-sm text-gray-300">{format(createdAt)}</span>
        </li>
      ))}
    </ul>
  );
}
tsx
import type { PropsWithChildren } from "react";
import { Container } from "../_components/Container";

type Props = PropsWithChildren;

export default function Layout({ children }: Props) {
  return <Container>{children}</Container>;
}
tsx
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import type { PropsWithChildren, ReactNode } from "react";
import { Footer } from "./_components/Footer";
import { Header } from "./_components/Header";
import { AuthProvider } from "./_providers/AuthProvider";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL),
  title: "web app template",
  description: "😸",
};

export const viewport: Viewport = {
  // for mobile
  maximumScale: 1,
};

type Props = PropsWithChildren<{
  dialog: ReactNode;
}>;

export default function Layout({ dialog, children }: Props) {
  return (
    <html lang="en">
      {/* if you don't use useSession, please remove AuthProvider */}
      <AuthProvider>
        <body
          className={[
            inter.className,
            "bg-gray-700 text-gray-200 min-h-screen flex flex-col",
            // for dialog
            "has-[dialog[open]]:overflow-hidden",
          ].join(" ")}
        >
          <Header />
          <main className="flex-1">{children}</main>
          <Footer />
          {dialog}
        </body>
      </AuthProvider>
    </html>
  );
}

SignIn (/signin)

What can you learn from this page?

  • Client Components
  • How to use React Hooks
  • How to sign in via NextAuth

Affected Layouts

  • app/layout.tsx
  • app/(public)/layout.tsx
  • app/(public)/signin/page.tsx
See Full Code
tsx
"use client";

import { signIn } from "next-auth/react";
import { Button } from "../../_components/Button";
import { useOnlineStatus } from "../../_hooks/useOnlineStatus";

export default function SignIn() {
  const { isOnline } = useOnlineStatus();

  return (
    <div className="flex flex-col items-center justify-center gap-6">
      {!isOnline && (
        <p className="text-sm text-red-300">🚨 Your network is online</p>
      )}
      <Button onClick={() => signIn("google")}>Sign in with Google</Button>
    </div>
  );
}
tsx
import type { PropsWithChildren } from "react";
import { Container } from "../_components/Container";

type Props = PropsWithChildren;

export default function Layout({ children }: Props) {
  return <Container>{children}</Container>;
}
tsx
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import type { PropsWithChildren, ReactNode } from "react";
import { Footer } from "./_components/Footer";
import { Header } from "./_components/Header";
import { AuthProvider } from "./_providers/AuthProvider";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL),
  title: "web app template",
  description: "😸",
};

export const viewport: Viewport = {
  // for mobile
  maximumScale: 1,
};

type Props = PropsWithChildren<{
  dialog: ReactNode;
}>;

export default function Layout({ dialog, children }: Props) {
  return (
    <html lang="en">
      {/* if you don't use useSession, please remove AuthProvider */}
      <AuthProvider>
        <body
          className={[
            inter.className,
            "bg-gray-700 text-gray-200 min-h-screen flex flex-col",
            // for dialog
            "has-[dialog[open]]:overflow-hidden",
          ].join(" ")}
        >
          <Header />
          <main className="flex-1">{children}</main>
          <Footer />
          {dialog}
        </body>
      </AuthProvider>
    </html>
  );
}

Me (/me)

What can you learn from this page?

  • Client Components
  • Client Components + Form + useActionState + Server Actions
  • How to retrieve the session with nextAuth on Client Components
  • Handling NotFound

Affected Layouts

  • app/layout.tsx
  • app/(private)/layout.tsx
  • app/(private)/me/page.tsx
See Full Code
tsx
"use client";

import clsx from "clsx";
import { useSession } from "next-auth/react";
import { notFound } from "next/navigation";
import { useActionState } from "react";
import { updateMe } from "../../_actions/users";
import { Button } from "../../_components/Button";

export default function Page() {
  const session = useSession();
  const [formState, formAction, isLoading] = useActionState(updateMe, {
    success: false,
  });

  if (session.status === "loading") {
    return <div>Loading...</div>;
  }

  if (session.status === "unauthenticated" || !session.data) {
    notFound();
  }

  return (
    <form className="flex flex-col gap-10 items-start" action={formAction}>
      <label className="flex gap-4 items-center">
        name
        <input
          type="text"
          name="name"
          defaultValue={formState.data?.name ?? session.data.user.name ?? ""}
          className="border border-gray-300 rounded px-2 py-0.5 bg-gray-600"
        />
      </label>
      <Button type="submit" className="bg-blue-500 px-8" disabled={isLoading}>
        Save
      </Button>
      {formState.message && (
        <span
          className={clsx(
            "text-sm",
            formState.success && "text-green-300",
            !formState.success && "text-red-300",
          )}
        >
          {formState.message}
        </span>
      )}
    </form>
  );
}
tsx
import type { PropsWithChildren } from "react";
import { Container } from "../_components/Container";

type Props = PropsWithChildren;

export default function Layout({ children }: Props) {
  return <Container>{children}</Container>;
}
tsx
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import type { PropsWithChildren, ReactNode } from "react";
import { Footer } from "./_components/Footer";
import { Header } from "./_components/Header";
import { AuthProvider } from "./_providers/AuthProvider";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL),
  title: "web app template",
  description: "😸",
};

export const viewport: Viewport = {
  // for mobile
  maximumScale: 1,
};

type Props = PropsWithChildren<{
  dialog: ReactNode;
}>;

export default function Layout({ dialog, children }: Props) {
  return (
    <html lang="en">
      {/* if you don't use useSession, please remove AuthProvider */}
      <AuthProvider>
        <body
          className={[
            inter.className,
            "bg-gray-700 text-gray-200 min-h-screen flex flex-col",
            // for dialog
            "has-[dialog[open]]:overflow-hidden",
          ].join(" ")}
        >
          <Header />
          <main className="flex-1">{children}</main>
          <Footer />
          {dialog}
        </body>
      </AuthProvider>
    </html>
  );
}

intercepting (/create)

This page is a Paralleled Routing and intercepted routeing so you can't access it directly. When you have signed in to this site, you will be able to see the "Add an item" button on the top page and you can access this page, which means users not signed in never access this page.

NOTE

In this case, this case is using intercepting routes purely as a sample, and they are not mandatory. However, as you can see from the directory structure, understanding these routes can be challenging. To make it simpler and easier to understand, we have included them for clarity.

What can you learn from this page?

  • Parallel Routing
  • Intercepting Routing
  • Client Components
  • Client Components + Form + react-hook-form + useTransition + Server Actions
  • Dialog Element

Affected Layouts

  • app/layout.tsx
  • @dialog/(.)create/page.tsx
See Full Code
tsx
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { type SubmitHandler, useForm } from "react-hook-form";
import { create } from "../../_actions/items";
import { type ItemCreateSchema, itemCreateSchema } from "../../_schemas/items";
import { Dialog } from "../_components/Dialog";

export function Content() {
  const router = useRouter();
  const [isPending, startTransition] = useTransition();
  const {
    register,
    formState: { errors },
    handleSubmit,
  } = useForm<ItemCreateSchema>({
    mode: "onChange",
    resolver: zodResolver(itemCreateSchema),
  });

  const submit: SubmitHandler<ItemCreateSchema> = async (values) => {
    if (isPending) {
      return;
    }

    startTransition(async () => {
      const { success, message } = await create(values);

      if (success) {
        router.push("/");
      } else {
        alert(message);
      }
    });
  };

  return (
    <Dialog>
      <div className="space-y-6">
        <h2 className="text-center">New memo</h2>
        <form onSubmit={handleSubmit(submit)} className="space-y-4">
          <input
            {...register("content")}
            id="content"
            disabled={isPending}
            placeholder="write your memo..."
            className="w-full bg-gray-600 text-gray-100 focus:outline-none py-3 px-5 rounded-sm"
            // ignore 1password
            data-1p-ignore
          />
          {errors.content?.message && (
            <span className="text-red-400">{errors.content.message}</span>
          )}
        </form>
      </div>
    </Dialog>
  );
}
tsx
import { Content } from "./Content";

// bug: https://github.com/vercel/next.js/issues/74128
export const dynamic = "force-dynamic";

// users who are not logged in cannot reach here due to intercepting routes.
export default function Page() {
  return <Content />;
}
tsx
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import type { PropsWithChildren, ReactNode } from "react";
import { Footer } from "./_components/Footer";
import { Header } from "./_components/Header";
import { AuthProvider } from "./_providers/AuthProvider";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL),
  title: "web app template",
  description: "😸",
};

export const viewport: Viewport = {
  // for mobile
  maximumScale: 1,
};

type Props = PropsWithChildren<{
  dialog: ReactNode;
}>;

export default function Layout({ dialog, children }: Props) {
  return (
    <html lang="en">
      {/* if you don't use useSession, please remove AuthProvider */}
      <AuthProvider>
        <body
          className={[
            inter.className,
            "bg-gray-700 text-gray-200 min-h-screen flex flex-col",
            // for dialog
            "has-[dialog[open]]:overflow-hidden",
          ].join(" ")}
        >
          <Header />
          <main className="flex-1">{children}</main>
          <Footer />
          {dialog}
        </body>
      </AuthProvider>
    </html>
  );
}

Why use Route Groups?

app
 ├── (private)
 └── (public)

The App Router inherits the parent's layout, making it challenging to differentiate the UI between logged-in and non-logged-in users. While it is possible to create separate directories, this approach results in the paths being reflected in the URL (e.g., /signed-in, /no-signed-in), which may not be the desired outcome.

Route Groups allow you to create directories without exposing them as paths, providing a flexible solution to this issue. It is recommended to adopt this structure from the beginning.