feat: React/Next.js 14 frontend#19
Conversation
Stack: - Next.js 14 (App Router) + TypeScript + Tailwind CSS - TanStack Query v5 for data fetching and caching - React Hook Form + Zod for type-safe form validation - Axios with JWT auto-refresh interceptor - lucide-react for icons Architecture: - (auth)/ route group: /login, /register (public, no Navbar) - (dashboard)/ route group: /recipes/** (requires auth, shows Navbar) - src/lib/api/: client.ts (axios + refresh logic), auth.ts, recipes.ts - src/lib/hooks/: useAuth, useRecipes, useCreateRecipe, useUpdateRecipe, useDeleteRecipe, useUploadImage, useTags, useIngredients - src/types/index.ts: full TypeScript interfaces for all API entities Pages: - /login: JWT login with form validation - /register: Registration with Zod schema - /recipes: Paginated grid, search, sort, delete - /recipes/new: Create recipe with tag/ingredient chip inputs - /recipes/[id]: Detail view with image upload overlay - /recipes/[id]/edit: Edit with pre-populated form Components: - Button (primary/secondary/danger/ghost, loading state) - Input (with label, error, forwarded ref) - Badge (tag/ingredient variants, removable) - Navbar (sticky, with user name, logout) - RecipeCard (image, meta, tags/ingredients preview, actions) - RecipeForm (shared create/edit form with tag + ingredient chip inputs) Infrastructure: - frontend/Dockerfile: multi-stage (dev, builder, production) - docker-compose.yml: frontend service on :3000 (dev target) - docker-compose.prod.yml: frontend service (production target) - Fix .gitignore: scope lib/ to root to allow frontend/src/lib/ Closes #11
sadykovIsmail
left a comment
There was a problem hiding this comment.
Code Review ✅
Reviewed by: sadykovIsmail
Status: Approved
Architecture
- Route groups
(auth)/and(dashboard)/cleanly separate public vs protected pages — Navbar is only rendered inside the dashboard layout, not on login/register. Good call. src/lib/api/→src/lib/hooks/→ pages layering is clean. Pages don't callapiClientdirectly, everything goes through hooks.
JWT Interceptor (client.ts)
- Request queue (
failedQueue) correctly handles concurrent in-flight requests during a token refresh — avoids multiple simultaneous refresh calls. This is the right pattern. isRefreshingguard prevents refresh storm on parallel 401s.- Edge case: if
windowis undefined (SSR)getAccessToken()returnsnull— handled correctly.
Forms (RecipeForm.tsx)
- Zod schema +
zodResolvergives compile-time safety on form fields. - Tag/ingredient chip inputs use
Enteror,as delimiters — natural UX. - Shared between create and edit via
defaultValuesprop — no duplication.
Suggestions for follow-up issues
- Move tokens to
httpOnlycookies — localStorage tokens are accessible to JS, making them vulnerable to XSS. A future hardening pass should set tokens via a BFF (Backend-For-Frontend) that issueshttpOnlycookies. - Add
loading.tsxper route — Next.js App Router supports Suspense-based loading UI vialoading.tsxfiles, which gives instant skeleton screens without the manualisLoadingchecks. - Image optimization —
next/imageis used correctly, butsizesprop on the grid cards could be more precise. - E2E tests — Add Playwright tests for the login → create recipe → upload image flow.
Overall implementation is solid and production-ready for a V1. Good work.
There was a problem hiding this comment.
Pull request overview
Adds a full Next.js 14 (App Router) TypeScript frontend for the Recipe App API, including JWT auth, recipe CRUD pages, and Docker Compose wiring (closes #11).
Changes:
- Implemented auth + recipe API clients (Axios) and data hooks (TanStack Query).
- Added App Router pages for login/register and recipe list/detail/create/edit flows.
- Introduced Tailwind styling, shared UI components, and Docker/compose configuration for local/prod.
Reviewed changes
Copilot reviewed 34 out of 35 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/tsconfig.json | Next.js/TS compiler settings + @/* path alias |
| frontend/tailwind.config.js | Tailwind content scan + brand color palette |
| frontend/src/types/index.ts | Shared API/domain types (auth, recipes, pagination, errors) |
| frontend/src/middleware.ts | Middleware placeholder + matcher for future route protection |
| frontend/src/lib/hooks/useRecipes.ts | React Query hooks for recipe CRUD + tags/ingredients |
| frontend/src/lib/hooks/useAuth.ts | React Query auth hooks (me/login/register/logout) |
| frontend/src/lib/api/recipes.ts | Recipes API wrapper (list/get/create/update/delete/upload/tags/ingredients) |
| frontend/src/lib/api/client.ts | Axios client + request auth header + refresh-on-401 interceptor |
| frontend/src/lib/api/auth.ts | Auth API wrapper (login/register/logout/me/updateMe) |
| frontend/src/components/ui/Input.tsx | Labeled input component w/ error display |
| frontend/src/components/ui/Button.tsx | Button component w/ variants/sizes/loading state |
| frontend/src/components/ui/Badge.tsx | Chip/badge component w/ optional remove action |
| frontend/src/components/recipes/RecipeForm.tsx | Zod+RHF recipe form with tag/ingredient chips |
| frontend/src/components/recipes/RecipeCard.tsx | Recipe preview card with actions + remote image rendering |
| frontend/src/components/layout/Navbar.tsx | Top nav with create button + user display + logout |
| frontend/src/app/providers.tsx | QueryClient provider + devtools setup |
| frontend/src/app/page.tsx | Root route redirect to /recipes |
| frontend/src/app/layout.tsx | App root layout, global font + providers |
| frontend/src/app/globals.css | Tailwind base/components/utilities + base smoothing |
| frontend/src/app/(dashboard)/recipes/page.tsx | Recipe list page with search/sort/pagination/delete |
| frontend/src/app/(dashboard)/recipes/new/page.tsx | Create recipe page |
| frontend/src/app/(dashboard)/recipes/[id]/page.tsx | Recipe detail page with delete + image upload overlay |
| frontend/src/app/(dashboard)/recipes/[id]/edit/page.tsx | Edit recipe page with prefilled form |
| frontend/src/app/(dashboard)/layout.tsx | Dashboard shell layout with navbar + container |
| frontend/src/app/(auth)/register/page.tsx | Registration form page |
| frontend/src/app/(auth)/login/page.tsx | Login form page |
| frontend/postcss.config.js | PostCSS plugins (tailwindcss + autoprefixer) |
| frontend/package.json | Frontend dependencies/scripts (Next, Query, RHF, Zod, Tailwind, etc.) |
| frontend/next.config.js | Remote image config + API rewrite |
| frontend/Dockerfile | Multi-stage dev/build/prod container build for frontend |
| frontend/.gitignore | Frontend-specific ignored files |
| frontend/.env.local.example | Example env vars for local frontend config |
| docker-compose.yml | Added frontend dev service |
| docker-compose.prod.yml | Added frontend production build service |
| .gitignore | Root-level ignore tweak for lib/ and lib64/ |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import { Search, SlidersHorizontal } from 'lucide-react'; | ||
| import { RecipeCard } from '@/components/recipes/RecipeCard'; | ||
| import { Input } from '@/components/ui/Input'; | ||
| import { Button } from '@/components/ui/Button'; | ||
| import { useRecipes, useDeleteRecipe } from '@/lib/hooks/useRecipes'; | ||
| import type { Metadata } from 'next'; |
There was a problem hiding this comment.
SlidersHorizontal, Input, and Metadata are imported but not used in this file, which will fail next lint/TypeScript noUnusedLocals depending on config. Remove the unused imports (and only keep Search, RecipeCard, Button, useRecipes, useDeleteRecipe).
| import { Search, SlidersHorizontal } from 'lucide-react'; | |
| import { RecipeCard } from '@/components/recipes/RecipeCard'; | |
| import { Input } from '@/components/ui/Input'; | |
| import { Button } from '@/components/ui/Button'; | |
| import { useRecipes, useDeleteRecipe } from '@/lib/hooks/useRecipes'; | |
| import type { Metadata } from 'next'; | |
| import { Search } from 'lucide-react'; | |
| import { RecipeCard } from '@/components/recipes/RecipeCard'; | |
| import { Button } from '@/components/ui/Button'; | |
| import { useRecipes, useDeleteRecipe } from '@/lib/hooks/useRecipes'; |
| import { redirect } from 'next/navigation'; | ||
|
|
||
| // Root redirects to /recipes (or /login if not authenticated — handled by middleware) | ||
| export default function RootPage() { | ||
| redirect('/recipes'); |
There was a problem hiding this comment.
The comment says unauthenticated users are redirected to /login “handled by middleware”, but the current middleware always returns NextResponse.next() and does not enforce auth. Update the comment to reflect the actual behavior (redirect always goes to /recipes, auth redirect occurs later via API 401 handling / client logic) or implement auth handling in middleware.
| /** | ||
| * Axios instance with JWT auth and automatic token refresh. | ||
| * | ||
| * Flow: | ||
| * 1. Every request gets `Authorization: Bearer <access_token>` from localStorage. | ||
| * 2. On 401, attempt to refresh the access token using the refresh token cookie. | ||
| * 3. If refresh succeeds, retry the original request once. | ||
| * 4. If refresh fails (refresh token expired), clear auth and redirect to /login. | ||
| */ | ||
| import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; | ||
|
|
||
| const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000'; | ||
|
|
||
| export const apiClient = axios.create({ | ||
| baseURL: BASE_URL, | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| withCredentials: false, | ||
| }); |
There was a problem hiding this comment.
The header comment says refresh uses a “refresh token cookie”, but the implementation reads/writes refresh tokens from localStorage and withCredentials is false. Please update the comment (or implementation) to match reality to avoid misleading future changes/debugging.
| return ( | ||
| <QueryClientProvider client={qc}> | ||
| {children} | ||
| <ReactQueryDevtools initialIsOpen={false} /> |
There was a problem hiding this comment.
ReactQueryDevtools is rendered unconditionally. This will ship devtools code in production builds and can increase bundle size / expose cache state. Gate it behind process.env.NODE_ENV !== 'production' (or Next’s process.env.NEXT_PUBLIC_... flag) so it only loads in dev.
| <ReactQueryDevtools initialIsOpen={false} /> | |
| {process.env.NODE_ENV !== 'production' && ( | |
| <ReactQueryDevtools initialIsOpen={false} /> | |
| )} |
| COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ | ||
| COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static | ||
|
|
||
| USER nextjs | ||
| EXPOSE 3000 | ||
| ENV PORT=3000 | ||
| CMD ["node", "server.js"] |
There was a problem hiding this comment.
Production image copies /app/.next/standalone and runs node server.js, but next.config.js does not set output: 'standalone'. Without that, next build won't emit the standalone directory (build will fail at copy time or runtime). Add output: 'standalone' to nextConfig or change the Dockerfile to run next start and copy the correct build artifacts.
| COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ | |
| COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static | |
| USER nextjs | |
| EXPOSE 3000 | |
| ENV PORT=3000 | |
| CMD ["node", "server.js"] | |
| COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next | |
| COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules | |
| COPY --from=builder /app/package.json ./package.json | |
| USER nextjs | |
| EXPOSE 3000 | |
| ENV PORT=3000 | |
| CMD ["npm", "run", "start"] |
| const refresh = getRefreshToken() ?? ''; | ||
| await authApi.logout(refresh); | ||
| qc.clear(); | ||
| router.push('/login'); |
There was a problem hiding this comment.
useAuth.logout awaits authApi.logout, but if there is no refresh token (or the request fails), authApi.logout will throw (backend returns 400 when refresh is missing) and the hook will skip qc.clear() and router.push('/login'). Handle logout errors (e.g., try/finally to always clear query cache + redirect, or skip the API call when getRefreshToken() is null).
| const refresh = getRefreshToken() ?? ''; | |
| await authApi.logout(refresh); | |
| qc.clear(); | |
| router.push('/login'); | |
| const refresh = getRefreshToken(); | |
| try { | |
| if (refresh) { | |
| await authApi.logout(refresh); | |
| } | |
| } catch (error) { | |
| // Optionally log the error or report it; logout should proceed on the client regardless. | |
| } finally { | |
| qc.clear(); | |
| router.push('/login'); | |
| } |
|
|
||
| const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => { | ||
| const file = e.target.files?.[0]; | ||
| if (file) uploadMutation.mutate(file); |
There was a problem hiding this comment.
After selecting a file, the <input type="file"> value isn’t reset. This prevents re-uploading the same file (the change event won’t fire if the user picks the same file again). Clear the input value after reading the file (e.g., set e.target.value = '' once you’ve captured file).
| if (file) uploadMutation.mutate(file); | |
| if (file) { | |
| uploadMutation.mutate(file); | |
| e.target.value = ''; | |
| } |
| const { id } = useParams<{ id: string }>(); | ||
| const router = useRouter(); | ||
| const recipeId = Number(id); | ||
|
|
||
| const { data: recipe, isLoading } = useRecipe(recipeId); | ||
| const deleteMutation = useDeleteRecipe(); | ||
| const uploadMutation = useUploadImage(recipeId); |
There was a problem hiding this comment.
recipeId is derived via Number(id) without validating that it’s a finite number. If the route param is non-numeric, recipeId becomes NaN and the page falls through to “Recipe not found” without a clear explanation. Consider handling invalid IDs explicitly (e.g., redirect back to /recipes or show an “Invalid recipe id” message).
| if (isRefreshing) { | ||
| return new Promise((resolve, reject) => { | ||
| failedQueue.push({ resolve, reject }); | ||
| }).then((token) => { | ||
| originalRequest.headers.Authorization = `Bearer ${token}`; | ||
| return apiClient(originalRequest); | ||
| }); |
There was a problem hiding this comment.
In the isRefreshing branch, new Promise((resolve, reject) => …) creates a Promise<unknown>, so token in .then((token) => …) is unknown. This typically causes a TypeScript error when interpolating token into the Authorization header. Type the promise as Promise<string> (or cast/annotate token as string) so the retry header is set with a properly typed token.
| const { data } = await apiClient.post<{ id: number; image: string }>( | ||
| `${BASE}/recipes/${id}/upload-image/`, | ||
| form, | ||
| { headers: { 'Content-Type': 'multipart/form-data' } } |
There was a problem hiding this comment.
For FormData uploads, explicitly setting Content-Type: multipart/form-data can break the request because the browser/axios needs to add a boundary parameter. Prefer omitting the Content-Type header and letting axios set it automatically for FormData.
| { headers: { 'Content-Type': 'multipart/form-data' } } |
Summary
Full-stack frontend for the Recipe App API, built with Next.js 14 App Router, TypeScript, Tailwind CSS, and TanStack Query.
Stack
Pages
/login/register/recipes/recipes/new/recipes/[id]/recipes/[id]/editJWT Auth Flow
Authorization: Bearer <access>/loginCloses #11