Web Application
The public web application is served at myapp.test and provides the end-user experience: marketing pages, authentication flows, user profile management, dynamic content pages, and multi-language navigation. It uses Next.js App Router with a mix of Server Components (for SEO-optimized content) and Client Components (for interactive features).
Page Map
myapp.test/
├── / Home page (i18n hero section)
├── /login Login (email/password or mobile/OTP)
├── /register User registration
├── /password/forgot Forgot password
├── /password/reset Reset password with token
├── /profile View profile (protected)
├── /profile/edit Edit name, email, avatar (protected)
├── /profile/password Change password (protected)
└── /[slug] Dynamic content pages (about, terms, etc.)Route Groups
The web app organizes routes into three groups using Next.js route group syntax:
app/
├── (auth)/ # Authentication pages
│ ├── layout.tsx # Centered card layout
│ ├── login/page.tsx
│ ├── register/page.tsx
│ └── password/
│ ├── forgot/page.tsx
│ └── reset/page.tsx
├── (protected)/ # Authenticated user pages
│ ├── layout.tsx # Auth guard + redirect
│ └── profile/
│ ├── page.tsx
│ ├── edit/page.tsx
│ └── password/page.tsx
├── [slug]/ # Dynamic content pages
│ ├── page.tsx # Server Component with SEO
│ └── not-found.tsx # 404 for missing content
├── layout.tsx # Root layout (providers, html dir)
└── page.tsx # Home pageHome Page
The home page renders a hero section with translated content pulled from the I18n provider. It includes the site header with dynamic navigation and a language switcher:
import { SiteHeader } from "@/components/site-header";
import { SiteFooter } from "@/components/site-footer";
export default function HomePage() {
return (
<div className="flex min-h-screen flex-col">
<SiteHeader />
<main className="flex-1">
<HeroSection />
{/* Additional content sections */}
</main>
<SiteFooter />
</div>
);
}The hero section uses the t() function for localized text, automatically switching between languages when the user changes their locale.
Authentication Pages
Auth Routes ((auth)/)
Auth pages share a centered card layout suitable for login and registration forms. These pages are accessible without authentication.
The login page supports two authentication methods based on your FORGE configuration:
| Method | Fields | Flow |
|---|---|---|
| Email/Password | Email + Password | Direct login with JWT response |
| Mobile/OTP | Mobile number + OTP code | Send OTP, then verify code |
// Login form submits to the API and stores the JWT token
const handleLogin = async (data: LoginFormData) => {
const response = await api.post<LoginResponse>("/auth/login", data);
localStorage.setItem("access_token", response.data.access_token);
router.push(redirect || "/");
};Registration
The registration page collects name, email, password, and password confirmation. After successful registration, the user is automatically logged in and redirected:
const handleRegister = async (data: RegisterFormData) => {
const response = await api.post<RegisterResponse>("/auth/register", data);
localStorage.setItem("access_token", response.data.access_token);
router.push("/");
};Password Recovery
The forgot password flow sends a reset link to the user's email. The reset page accepts the token from the URL, validates it, and allows the user to set a new password:
User clicks "Forgot Password"
│
▼
Enter email → POST /auth/forgot-password
│
▼
Email with reset link → /password/reset?token=xxx&email=user@example.com
│
▼
Enter new password → POST /auth/reset-password
│
▼
Redirect to /login with success messageProtected Routes
Auth Guard ((protected)/)
The protected route group uses a layout component that checks authentication status. If the user is not authenticated, they are redirected to the login page with the current path preserved:
// app/(protected)/layout.tsx
"use client";
import { useAuth } from "@/components/providers/auth-provider";
import { useRouter, usePathname } from "next/navigation";
import { useEffect } from "react";
export default function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
const { user, isLoading } = useAuth();
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
if (!isLoading && !user) {
router.push(`/login?redirect=${encodeURIComponent(pathname)}`);
}
}, [user, isLoading, pathname, router]);
if (isLoading) {
return <LoadingSkeleton />;
}
if (!user) {
return null;
}
return <>{children}</>;
}WARNING
Protected routes redirect on the client side via the AuthProvider. The backend API also enforces authentication on its endpoints, so even if a user bypasses the frontend guard, protected data remains inaccessible.
Profile Pages
Authenticated users can manage their profile through three dedicated pages:
| Page | Route | Features |
|---|---|---|
| View Profile | /profile | Display name, email, avatar, account info |
| Edit Profile | /profile/edit | Update name, email (requires password), avatar upload |
| Change Password | /profile/password | Current password + new password with confirmation |
Avatar Upload
The profile edit page includes an avatar upload component that previews the selected image before uploading:
const handleAvatarUpload = async (file: File) => {
try {
const response = await api.upload<{
data: { url: string; id: string };
}>("/media", file, "file");
setAvatarUrl(response.data.url);
toast.success(t("profile.Avatar_uploaded_successfully"));
} catch (error) {
toast.error(getErrorMessage(error));
}
};Profile forms use Zod validation schemas and React Hook Form, following the same patterns documented in Forms & Validation.
Dynamic Content Pages
Content Routes ([slug]/)
Content pages use a dynamic [slug] route that fetches content from the backend API. These are Server Components for optimal SEO:
// app/[slug]/page.tsx
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import { SiteHeader } from "@/components/site-header";
import { SiteFooter } from "@/components/site-footer";
async function getContent(slug: string, locale?: string) {
const apiUrl = process.env.INTERNAL_API_URL
|| process.env.NEXT_PUBLIC_API_URL;
const url = locale
? `${apiUrl}/api/v1/contents/${slug}?locale=${locale}`
: `${apiUrl}/api/v1/contents/${slug}`;
const res = await fetch(url, {
next: { revalidate: 60 },
});
if (!res.ok) return null;
const json = await res.json();
return json.data;
}
export default async function ContentPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const headersList = await headers();
const locale = headersList.get("x-locale") || "en";
const content = await getContent(slug, locale);
if (!content) notFound();
return (
<div className="flex min-h-screen flex-col">
<SiteHeader />
<main className="flex-1">
{content.featured_image_url && (
<section className="relative h-[400px] overflow-hidden">
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: `url(${content.featured_image_url})`,
}}
/>
</section>
)}
{content.details && (
<section className="container py-12">
<div className="max-w-3xl mx-auto">
{/* Content HTML is managed by admin users via the CMS */}
<div
className="prose prose-lg dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: content.details }}
/>
</div>
</section>
)}
</main>
<SiteFooter />
</div>
);
}WARNING
The dangerouslySetInnerHTML usage here renders trusted HTML content authored by admin users through the CMS rich text editor. This content originates from authenticated admin endpoints, not from public user input. If you extend this pattern with user-generated content, sanitize the HTML with a library like DOMPurify before rendering.
TIP
Content pages use next: { revalidate: 60 } for Incremental Static Regeneration. Pages are statically generated on first request and refreshed every 60 seconds, giving you both performance and freshness.
SEO
Dynamic content pages generate full metadata from the content's SEO fields:
export async function generateMetadata({
params,
}: ContentPageProps): Promise<Metadata> {
const { slug } = await params;
const headersList = await headers();
const locale = headersList.get("x-locale") || "en";
const content = await getContent(slug, locale);
if (!content) {
return { title: "Page Not Found" };
}
return {
title: content.seo?.title || content.title,
description: content.seo?.description || content.summary,
keywords: content.seo?.keywords,
robots: content.seo?.robots,
alternates: content.seo?.canonical
? { canonical: content.seo.canonical }
: undefined,
openGraph: {
title: content.seo?.title || content.title,
description: content.seo?.description || content.summary,
images: content.featured_image_url
? [{ url: content.featured_image_url }]
: undefined,
},
};
}The admin panel's content editor provides fields for all SEO properties (title, description, keywords, robots, canonical URL) per content page and per language.
Dynamic Navigation
The web app's header and footer render navigation menus fetched from the backend Menus API. This allows site administrators to manage navigation structure from the admin panel without deploying code changes:
// components/dynamic-nav.tsx
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useI18n } from "@/components/providers/i18n-provider";
import type { PublicMenu } from "@/lib/types";
export function DynamicNav() {
const { locale } = useI18n();
const [menus, setMenus] = useState<PublicMenu[]>([]);
useEffect(() => {
const fetchMenus = async () => {
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
const res = await fetch(
`${apiUrl}/api/v1/menus?locale=${locale}`
);
if (res.ok) {
const json = await res.json();
setMenus(json.data || []);
}
};
fetchMenus();
}, [locale]);
return (
<nav className="flex items-center gap-6">
{menus.map((item) => (
<Link
key={item.id}
href={item.url || "#"}
target={item.target}
className="text-sm font-medium transition-colors hover:text-primary"
>
{item.title}
</Link>
))}
</nav>
);
}TIP
Menu items support visibility settings: public (always shown), auth (shown only to authenticated users), and guest (shown only to unauthenticated users). This is enforced at the API level based on the request's authentication state.
Language Switcher
The web app includes a language switcher dropdown in the header. When a user switches languages:
- Sets
localeanddirectioncookies (shared across subdomains) - Updates
localStorageas a fallback - Sets
document.documentElement.diranddocument.documentElement.lang - Updates the I18n context state (triggers re-render of all translated content)
- Calls
router.refresh()to update server-rendered content
import { Globe } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useI18n } from "@/components/providers/i18n-provider";
export function LanguageSwitcher() {
const { locale, languages, setLocale } = useI18n();
if (languages.length <= 1) return null;
const currentLang = languages.find((l) => l.code === locale);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="gap-2">
<Globe className="h-4 w-4" />
<span className="hidden sm:inline">
{currentLang?.native_name || "Language"}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{languages.map((lang) => (
<DropdownMenuItem
key={lang.code}
onClick={() => setLocale(lang.code)}
className={locale === lang.code ? "bg-accent" : ""}
>
{lang.native_name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}The component automatically hides itself when only one language is configured.
RTL Support
The web app fully supports right-to-left languages. When a user switches to an RTL language (e.g., Arabic), the entire layout mirrors:
- The
<html dir="rtl">attribute flips the document direction - CSS logical properties (
ms-,me-,ps-,pe-) adapt automatically - The header and footer content reflows to the correct side
- The rich text content within
[slug]pages renders in the correct direction
For more details, see Internationalization.
What's Next
- Admin Dashboard -- Admin panel and CRUD pages
- Components -- Shared component library
- Forms & Validation -- Form handling patterns
- Internationalization -- Multi-language and RTL support