Skip to content

Routing and Navigation

RednGift requires organized navigation between:

  • Public pages: Landing, invitation acceptance
  • Authentication pages: Login, registration, magic link confirmation
  • Protected pages: Groups, wishlists, chat, settings
  • SEO: Static pages should be server-rendered for search engines
  • Performance: Code-split routes to reduce initial bundle size
  • Type safety: Prevent typos in navigation paths
  • Access control: Enforce authentication before rendering protected pages
  • Layout reuse: Share navbar/footer across route groups
  • File-based routing: Folder structure defines URL paths automatically
  • Layouts: Shared UI across route hierarchies
  • Server components: Reduce client JavaScript by default
  • Streaming: Progressive page rendering with React Suspense
  • Metadata API: Type-safe SEO tags and OpenGraph
  • Security: Prevent unauthorized access to user data
  • UX: Auto-redirect unauthenticated users to login
  • Loading states: Show skeleton while checking auth status
src/app/
├── layout.tsx # Root layout (providers, theme)
├── page.tsx # Public home page (/)
├── not-found.tsx # 404 page
├── loading.tsx # Global loading fallback
├── auth/
│ ├── layout.tsx # Auth layout (GuestGuard, centered card)
│ ├── login/
│ │ └── page.tsx # /auth/login
│ ├── register/
│ │ └── page.tsx # /auth/register
│ ├── confirm/
│ │ └── page.tsx # /auth/confirm (email confirmation)
│ └── magic/
│ └── [token]/
│ └── page.tsx # /auth/magic/:token (passwordless login)
├── (home)/ # Route group (doesn't affect URL)
│ ├── layout.tsx # Authenticated layout (AuthGuard, navbar)
│ ├── page.tsx # /home (dashboard)
│ ├── group/
│ │ ├── create/
│ │ │ └── page.tsx # /group/create
│ │ ├── [id]/
│ │ │ ├── page.tsx # /group/:id
│ │ │ ├── edit/
│ │ │ │ └── page.tsx # /group/:id/edit
│ │ │ ├── invite/
│ │ │ │ ├── page.tsx # /group/:id/invite
│ │ │ │ └── batch/
│ │ │ │ └── page.tsx # /group/:id/invite/batch
│ │ │ └── wishlists/
│ │ │ ├── page.tsx # /group/:id/wishlists
│ │ │ └── [wishlistId]/
│ │ │ └── page.tsx # /group/:id/wishlists/:wishlistId
│ ├── inbox/
│ │ ├── page.tsx # /inbox
│ │ └── [id]/
│ │ └── page.tsx # /inbox/:id
│ ├── items/
│ │ └── page.tsx # /items (trending items)
│ ├── wishlist/
│ │ ├── create/
│ │ │ └── page.tsx # /wishlist/create
│ │ └── [id]/
│ │ └── edit/
│ │ ├── page.tsx # /wishlist/:id/edit
│ │ └── items/
│ │ └── page.tsx # /wishlist/:id/edit/items
│ └── settings/
│ └── profile/
│ ├── page.tsx # /settings/profile
│ └── wishlists/
│ └── page.tsx # /settings/profile/wishlists
├── invite/
│ ├── layout.tsx # Invitation layout (minimal UI)
│ └── [slug]/
│ └── page.tsx # /invite/:slug (public group invite)
└── terminos-y-condiciones/
└── page.tsx # /terminos-y-condiciones (legal)

Route groups (home): Parentheses prevent folder name from appearing in URL.

src/routes/paths.ts
const ROOTS = {
AUTH: '/auth',
HOME: '/',
};
export const paths = {
auth: {
confirm: `${ROOTS.AUTH}/confirm`,
login: `${ROOTS.AUTH}/login`,
register: `${ROOTS.AUTH}/register`,
magic: (magicToken: string) => `${ROOTS.AUTH}/magic/${magicToken}`,
},
home: {
main: ROOTS.HOME,
group: {
main: (id: number | string) => `${ROOTS.HOME}group/${id}`,
create: `${ROOTS.HOME}group/create`,
edit: (id: number | string) => `${ROOTS.HOME}group/${id}/edit`,
wishlists: {
main: (id: number | string) => `${ROOTS.HOME}group/${id}/wishlists`,
wishlist: (idGroup: number | string, idWishlist: number | string) =>
`${ROOTS.HOME}group/${idGroup}/wishlists/${idWishlist}`,
},
invite: {
main: (id: number | string, back?: string) =>
`${ROOTS.HOME}group/${id}/invite${back === 'true' ? '?back=1' : '?back=0'}`,
batch: (id: number | string, group?: string) =>
`${ROOTS.HOME}group/${id}/invite/batch${group === 'true' ? '?group=1' : '?group=0'}`,
},
},
inbox: {
main: `${ROOTS.HOME}inbox`,
id: (id: number | string) => `${ROOTS.HOME}inbox/${id}`,
},
wishlist: {
create: `${ROOTS.HOME}wishlist/create`,
edit: {
main: (id: number | string) => `${ROOTS.HOME}wishlist/${id}/edit?wishlist=${id}`,
editItems: (id: number | string) => `${ROOTS.HOME}wishlist/${id}/edit/items`,
},
},
settings: {
profile: {
main: `${ROOTS.HOME}settings/profile`,
wishlists: `${ROOTS.HOME}settings/profile/wishlists`,
},
},
},
};

Benefits:

  • Type-safe navigation: TypeScript catches invalid paths at compile time
  • Single source of truth: Change URL structure in one place
  • Auto-complete: IDE suggests available paths
// src/auth/guard/auth-guard.tsx (simplified)
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthContext } from 'src/auth/hooks';
import LoadingScreen from 'src/components/loading-screen';
export function AuthGuard({ children }: Props) {
const { authenticated, loading } = useAuthContext();
const router = useRouter();
useEffect(() => {
if (!loading && !authenticated) {
router.replace('/auth/login');
}
}, [authenticated, loading, router]);
if (loading) {
return <LoadingScreen />;
}
if (!authenticated) {
return null; // Will redirect in useEffect
}
return <>{children}</>;
}

Usage:

// src/app/(home)/layout.tsx
export default function Layout({ children }: Props) {
return (
<AuthGuard>
<WebSocketProvider>
<HomeLayout>{children}</HomeLayout>
</WebSocketProvider>
</AuthGuard>
);
}
// src/auth/guard/guest-guard.tsx (simplified)
export function GuestGuard({ children }: Props) {
const { authenticated, loading } = useAuthContext();
const router = useRouter();
useEffect(() => {
if (!loading && authenticated) {
router.replace('/'); // Redirect to home if already logged in
}
}, [authenticated, loading, router]);
if (loading || authenticated) {
return <LoadingScreen />;
}
return <>{children}</>;
}

Usage: Wrap /auth/login and /auth/register layouts.

src/auth/guard/role-based-guard.tsx
export function RoleBasedGuard({ children, allowedRoles }: Props) {
const { user } = useAuthContext();
if (!allowedRoles.includes(user?.role)) {
return <ForbiddenPage />;
}
return <>{children}</>;
}

Assumption: Role-based access control not currently implemented.

User clicks <Link href={paths.home.group.main(5)}>
Next.js Router intercepts
Pre-fetch page chunk (if in viewport)
Navigate to /group/5
Render layout hierarchy:
layout.tsx (root)
└─ (home)/layout.tsx
└─ group/[id]/page.tsx
Execute guards (AuthGuard)
├─ Authenticated ─────────────┐
│ │
│ ▼
│ Render page
└─ Unauthenticated ───────────┐
Redirect to /auth/login
Section titled “2. Deep Link Access (User Types URL Directly)”
User visits /group/5/wishlists
Next.js loads page bundle
AuthProvider initializes
├─ Token valid ──────────────┐
│ │
│ ▼
│ Set user in context
│ │
│ ▼
│ AuthGuard passes
│ │
│ ▼
│ Render /group/5/wishlists
└─ No token ─────────────────┐
AuthGuard redirects to /auth/login
Store return URL: /group/5/wishlists
After login, redirect back
// Component code
const router = useRouter();
const handleCreateGroup = async () => {
const group = await groupsHandlers.createNewGroup(data);
router.push(paths.home.group.main(group.id));
};

Methods:

  • router.push(path): Navigate with back button support
  • router.replace(path): Navigate without history entry
  • router.back(): Go to previous page
  • router.refresh(): Re-fetch server data without full reload

DO:

  • Use paths object for all navigation: <Link href={paths.home.inbox.main} />
  • Use dynamic segments [id] for variable routes
  • Wrap layouts with guards, not individual pages
  • Pre-fetch critical routes: <Link prefetch href={...} />

DON’T:

  • Don’t hardcode URLs: <Link href="/group/5" /> (use paths.home.group.main(5))
  • Don’t nest AuthGuard multiple times (apply once at layout level)
  • Don’t use window.location.href for client-side navigation (loses SPA behavior)
// Reading query params
import { useSearchParams } from 'next/navigation';
const searchParams = useSearchParams();
const back = searchParams.get('back'); // /group/5/invite?back=1

Setting query params:

const router = useRouter();
router.push(`${paths.home.group.invite.main(id)}?back=1`);
// src/app/(home)/group/[id]/page.tsx
import { Metadata } from 'next';
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const group = await groupsHandlers.getGroupById(params.id);
return {
title: `${group.name} - RednGift`,
description: `Join ${group.name} for Secret Santa gift exchange`,
openGraph: {
title: group.name,
images: [group.image],
},
};
}
  • 404 pages: Create not-found.tsx at any level
  • Error boundaries: Create error.tsx at any level
// src/app/(home)/group/[id]/error.tsx
'use client';
export default function Error({ error, reset }: Props) {
return (
<div>
<h2>Failed to load group</h2>
<button onClick={reset}>Try again</button>
</div>
);
}
  • Automatic code splitting: Each route is a separate chunk
  • Pre-fetching: Links in viewport are pre-fetched on hover
  • Parallel routes: Fetch multiple route segments simultaneously
  • Streaming: Use loading.tsx to show skeleton while data loads
// src/app/(home)/group/[id]/loading.tsx
export default function Loading() {
return <Skeleton variant="rectangular" height={400} />;
}
import { renderHook } from '@testing-library/react';
import { AuthGuard } from './auth-guard';
it('should redirect unauthenticated users', () => {
jest.spyOn(useAuthContext, 'useAuthContext').mockReturnValue({
authenticated: false,
loading: false,
});
const routerPush = jest.fn();
jest.spyOn(useRouter, 'useRouter').mockReturnValue({ push: routerPush });
renderHook(() => <AuthGuard><div /></AuthGuard>);
expect(routerPush).toHaveBeenCalledWith('/auth/login');
});
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
it('should navigate to group page on link click', async () => {
render(<GroupCard groupId={5} />);
const link = screen.getByRole('link', { name: /view group/i });
await userEvent.click(link);
expect(window.location.pathname).toBe('/group/5');
});
  • Navigate to protected route when logged out → redirects to login
  • Log in → redirects back to original URL
  • Navigate through multi-level routes (group → wishlists → items)
src/routes/paths.ts
export const paths = {
admin: {
dashboard: '/admin/dashboard',
users: '/admin/users',
reports: '/admin/reports',
},
};
// src/app/admin/layout.tsx
import { RoleBasedGuard } from 'src/auth/guard';
export default function AdminLayout({ children }: Props) {
return (
<AuthGuard>
<RoleBasedGuard allowedRoles={['admin']}>
{children}
</RoleBasedGuard>
</AuthGuard>
);
}
src/app/[lang]/layout.tsx
export async function generateStaticParams() {
return [{ lang: 'en' }, { lang: 'es' }];
}
export default function LocaleLayout({ children, params }: Props) {
return (
<IntlProvider locale={params.lang}>
{children}
</IntlProvider>
);
}

URLs become /en/group/5 and /es/group/5.

Track page views:

src/app/layout.tsx
'use client';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect } from 'react';
export default function RootLayout({ children }: Props) {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
const url = `${pathname}?${searchParams}`;
gtag('config', GA_TRACKING_ID, { page_path: url });
}, [pathname, searchParams]);
return children;
}