Skip to content

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 page

Home 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:

tsx
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:

MethodFieldsFlow
Email/PasswordEmail + PasswordDirect login with JWT response
Mobile/OTPMobile number + OTP codeSend OTP, then verify code
tsx
// 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:

tsx
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 message

Protected 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:

tsx
// 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:

PageRouteFeatures
View Profile/profileDisplay name, email, avatar, account info
Edit Profile/profile/editUpdate name, email (requires password), avatar upload
Change Password/profile/passwordCurrent password + new password with confirmation

Avatar Upload

The profile edit page includes an avatar upload component that previews the selected image before uploading:

tsx
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:

tsx
// 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:

tsx
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:

tsx
// 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:

  1. Sets locale and direction cookies (shared across subdomains)
  2. Updates localStorage as a fallback
  3. Sets document.documentElement.dir and document.documentElement.lang
  4. Updates the I18n context state (triggers re-render of all translated content)
  5. Calls router.refresh() to update server-rendered content
tsx
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

Released under the MIT License.