Skip to content

Commit 23596f8

Browse files
committed
feat: use react page title and cache server pages
1 parent 2070e51 commit 23596f8

File tree

7 files changed

+70
-45
lines changed

7 files changed

+70
-45
lines changed

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,13 @@ over the data loading and give the users benefits of frontend cache data while t
151151
and forwards in the search page.
152152

153153
I have also used this on the movie details page. You could argue here that the frontend cache is not as useful
154-
as caching on the search page But should the users again go back and forth between search and movie details pages
155-
maybe view the same page movie details multiple times then this will feel a little more performant to the user.
156-
I also decide to be consistent across the two pages how their data is initialised and hydrated.
154+
as caching on the search page. I also decide to be consistent across the two pages in how their data is initialised and hydrated.
155+
So we have a standard data loading pattern.
156+
157+
Currently the Next.js Link component isn't always able to provide "Soft navigation",
158+
where routing is solely via the frontend rather instead a full page load - but once this problem is resolved the page could take full
159+
advantage of cache. In the meantime the page can be cached by setting the revalidate time of page.
160+
157161

158162
### Styling
159163

src/app/layout.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ const roboto = Roboto({
1010
});
1111

1212
export const metadata: Metadata = {
13-
title: 'Search for Movies',
1413
description: 'Discover movies with powerful search and detailed views.',
1514
};
1615

src/app/movies/[movieId]/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { fetchMovieDetails } from '@/lib/fetch/fetchMovieDetails';
55
import MovieDetailsSection from '@/features/movies/MovieDetailsSection';
66
import { movieDetailQueryKey } from '@/lib/queryKeys';
77

8+
export const revalidate = 3600;
9+
810
const movieIdSlugSchema = z.string().regex(/^(\d+)-.+$/, 'Invalid movie slug format');
911

1012
type MoviePageProps = {

src/app/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { HomeSearchSection } from '@/features/home/HomeSearchSection';
44
import { getQueryClient } from '@/lib/getQueryClient';
55
import { moviesQueryKey } from '@/lib/queryKeys';
66

7+
export const revalidate = 300;
8+
79
interface HomePageProps {
810
searchParams: Promise<Record<string, string | undefined>>;
911
}

src/components/Pagination.tsx

Lines changed: 49 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
'use client';
22

33
import { AiFillCaretLeft, AiFillCaretRight } from 'react-icons/ai';
4-
import { MouseEventHandler } from 'react';
4+
import { HTMLAttributes } from 'react';
55

6-
interface PaginationProps extends React.HTMLAttributes<HTMLElement> {
6+
interface PaginationProps extends HTMLAttributes<HTMLElement> {
77
currentPage: number;
88
totalPages: number;
99
onPageChange: (page: number) => void;
10+
search?: string;
1011
readonly?: boolean;
1112
}
1213

@@ -16,26 +17,42 @@ const getPageRange = (currentPage: number, totalPages: number): number[] => {
1617
);
1718
};
1819

20+
function buildHref(page: number, search?: string) {
21+
const params = new URLSearchParams();
22+
if (search) params.set('search', search);
23+
if (page > 1) params.set('page', String(page));
24+
return `?${params.toString()}`;
25+
}
26+
1927
export function Pagination({
2028
currentPage,
2129
totalPages,
2230
onPageChange,
31+
search,
2332
readonly = false,
2433
...rest
2534
}: PaginationProps) {
26-
const buttonBase = 'px-2 py-1 rounded transition text-purple-800 hover:underline focus:underline';
27-
const buttonCursor = readonly ? 'cursor-not-allowed' : 'cursor-pointer';
35+
const linkBase = 'px-2 py-1 rounded transition text-purple-800 hover:underline focus:underline';
36+
const linkCursor = readonly ? 'cursor-not-allowed' : 'cursor-pointer';
2837

2938
const readonlyProps = {
30-
disabled: readonly || undefined,
3139
tabIndex: readonly ? -1 : undefined,
40+
'aria-disabled': readonly ? true : undefined,
3241
};
3342

34-
const createPageHandler: (page: number) => MouseEventHandler<HTMLButtonElement> =
35-
(page) => (e) => {
43+
// Handles SPA soft navigation and fallback to native on middle-click/new tab
44+
const handleLinkClick = (page: number) => (e: React.MouseEvent<HTMLAnchorElement>) => {
45+
if (readonly) {
3646
e.preventDefault();
37-
if (!readonly) onPageChange(page);
38-
};
47+
return;
48+
}
49+
// Allow ctrl/cmd+click, shift+click (open in new tab/window)
50+
if (e.defaultPrevented || e.metaKey || e.altKey || e.ctrlKey || e.shiftKey || e.button !== 0) {
51+
return;
52+
}
53+
e.preventDefault();
54+
onPageChange(page);
55+
};
3956

4057
const pageRange = getPageRange(currentPage, totalPages);
4158

@@ -49,67 +66,71 @@ export function Pagination({
4966
>
5067
<div className="w-4 flex justify-start">
5168
{currentPage > 1 && (
52-
<button
53-
onClick={createPageHandler(currentPage - 1)}
54-
className={`${buttonBase} ${buttonCursor}`}
69+
<a
70+
href={buildHref(currentPage - 1, search)}
5571
aria-label="Previous page"
72+
className={`${linkBase} ${linkCursor}`}
73+
onClick={handleLinkClick(currentPage - 1)}
5674
{...readonlyProps}
5775
>
5876
<AiFillCaretLeft aria-hidden="true" />
59-
</button>
77+
</a>
6078
)}
6179
</div>
6280

6381
<div className="flex gap-1 items-center">
6482
{pageRange.map((p) =>
6583
p === currentPage ? (
66-
<button
84+
<a
6785
key={`${currentPage}-${p}`}
6886
aria-current="page"
69-
disabled
7087
tabIndex={-1}
7188
className="px-2 py-1 rounded bg-purple-950 text-white pointer-events-none cursor-not-allowed"
89+
href={buildHref(p, search)}
7290
>
7391
{p}
74-
</button>
92+
</a>
7593
) : (
76-
<button
94+
<a
7795
key={`${currentPage}-${p}`}
78-
onClick={createPageHandler(p)}
96+
href={buildHref(p, search)}
7997
aria-label={`Page ${p}`}
80-
className={`${buttonBase} ${buttonCursor}`}
98+
className={`${linkBase} ${linkCursor}`}
99+
onClick={handleLinkClick(p)}
81100
{...readonlyProps}
82101
>
83102
{p}
84-
</button>
103+
</a>
85104
)
86105
)}
87106

88107
{!pageRange.includes(totalPages) && (
89108
<span className="flex gap-2">
90109
<span aria-hidden="true"></span>
91-
<button
92-
onClick={createPageHandler(totalPages)}
93-
className={`${buttonBase} ${buttonCursor}`}
110+
<a
111+
href={buildHref(totalPages, search)}
112+
className={`${linkBase} ${linkCursor}`}
94113
aria-label={`Page ${totalPages}`}
114+
onClick={handleLinkClick(totalPages)}
95115
{...readonlyProps}
96116
>
97117
{totalPages}
98-
</button>
118+
</a>
99119
</span>
100120
)}
101121
</div>
102122

103123
<div className="w-4 flex justify-end">
104124
{currentPage < totalPages && (
105-
<button
106-
onClick={createPageHandler(currentPage + 1)}
107-
className={`${buttonBase} ${buttonCursor}`}
125+
<a
126+
href={buildHref(currentPage + 1, search)}
108127
aria-label="Next page"
128+
className={`${linkBase} ${linkCursor}`}
129+
onClick={handleLinkClick(currentPage + 1)}
109130
{...readonlyProps}
110131
>
111132
<AiFillCaretRight aria-hidden="true" />
112-
</button>
133+
</a>
113134
)}
114135
</div>
115136
</nav>

src/features/home/HomeSearchSection.tsx

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,6 @@ export function HomeSearchSection({ initialSearch, initialPage }: Props) {
5656
enabled: !!params.search,
5757
});
5858

59-
useEffect(() => {
60-
let title: string;
61-
if (params.search) {
62-
title =
63-
params.page && params.page > 1
64-
? `Search: ${params.search} (Page ${params.page}) | Movie Search`
65-
: `Search: ${params.search} | Movie Search`;
66-
} else {
67-
title = 'Movie Search';
68-
}
69-
document.title = title;
70-
}, [params.search, params.page]);
71-
7259
useEffect(() => {
7360
if (isSuccess && data) {
7461
lastTotalPagesRef.current = data.total_pages;
@@ -85,8 +72,15 @@ export function HomeSearchSection({ initialSearch, initialPage }: Props) {
8572

8673
const handlePageChange = (page: number) => setParams({ page });
8774

75+
const title = params.search
76+
? params.page && params.page > 1
77+
? `Search: ${params.search} (Page ${params.page}) | Movie Search`
78+
: `Search: ${params.search} | Movie Search`
79+
: 'Movie Search';
80+
8881
return (
8982
<section className="space-y-4">
83+
<title>{title}</title>
9084
<h1 className="text-xl font-bold text-stone-800">Welcome to Movie Search</h1>
9185
<p className="text-stone-600">
9286
Use the search box to find your favorite movies. Results will appear below.
@@ -117,6 +111,7 @@ export function HomeSearchSection({ initialSearch, initialPage }: Props) {
117111
{showPaging && (
118112
<Pagination
119113
data-testid="pagination-top"
114+
search={params.search}
120115
currentPage={currentPage}
121116
totalPages={totalPages}
122117
onPageChange={handlePageChange}
@@ -141,6 +136,7 @@ export function HomeSearchSection({ initialSearch, initialPage }: Props) {
141136
</div>
142137
{showPaging && (
143138
<Pagination
139+
search={params.search}
144140
currentPage={currentPage}
145141
totalPages={totalPages}
146142
onPageChange={handlePageChange}

src/features/movies/MovieDetailsSection.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export default function MovieDetailsSection({ movieId }: Props) {
2626

2727
return (
2828
<section className="space-y-4">
29+
<title>{`Search for movies: ${data.title}`}</title>
2930
<BackToSearchLink />
3031
<MovieDetailsView movie={data} />
3132
</section>

0 commit comments

Comments
 (0)