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
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>
);
}
import type { PropsWithChildren } from "react";
import { Container } from "../_components/Container";
type Props = PropsWithChildren;
export default function Layout({ children }: Props) {
return <Container>{children}</Container>;
}
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
"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>
);
}
import type { PropsWithChildren } from "react";
import { Container } from "../_components/Container";
type Props = PropsWithChildren;
export default function Layout({ children }: Props) {
return <Container>{children}</Container>;
}
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
"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>
);
}
import type { PropsWithChildren } from "react";
import { Container } from "../_components/Container";
type Props = PropsWithChildren;
export default function Layout({ children }: Props) {
return <Container>{children}</Container>;
}
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
"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>
);
}
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 />;
}
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.