A production-ready Next.js template with authentication, protected routes, and modular architecture.
- Next.js 16 (App Router)
- React 19
- TypeScript 5
- TanStack Query (React Query)
- React Hook Form + Zod
- Axios
- next-themes
- Tailwind CSS 4
- ESLint 9 + Prettier
- Untitled UI React
pnpm install
pnpm devOpen http://localhost:3000 with your browser to see the result.
pnpm dev # Start development server
pnpm build # Build for production
pnpm start # Start production server
pnpm lint # Run ESLint
pnpm format # Format with Prettier
pnpm type-check # TypeScript type checkingCreate a .env.local file:
NEXT_PUBLIC_API_BASE_URL=http://localhost:4000/api
# Optional: Token lifetimes in seconds
# ACCESS_TOKEN_MAX_AGE=900 # Default: 15 minutes
# REFRESH_TOKEN_MAX_AGE=604800 # Default: 7 daysEnvironment validation runs at startup. Missing required variables will throw an error.
Build and run with Docker Compose:
docker compose up --buildOr build manually:
docker build -t frontend-template --build-arg NEXT_PUBLIC_API_BASE_URL=http://localhost:4000/api .
docker run -p 3000:3000 frontend-templateConfigure environment variables in docker-compose.yml or pass them at runtime.
src/
├── app/
│ ├── (exposed)/ # Public pages (accessible by everyone)
│ ├── (protected)/ # Authenticated users only (with server prefetch)
│ ├── auth/ # Non-authenticated users only
│ ├── api/ # API routes
│ ├── error.tsx # Error boundary
│ └── global-error.tsx # Global error boundary
├── components/
│ ├── base/
│ │ ├── _rhf/ # React Hook Form adapted components
│ │ └── [component]/ # Base UI components
│ ├── guards/ # Route guards (EmailVerifiedGuard)
│ ├── error-boundary.tsx # Client error boundary
│ └── page-loading.tsx # Full page loading
├── contexts/ # React contexts
├── libs/
│ ├── cookies/ # Cookie utilities
│ ├── server/ # Server-side utilities
│ ├── env.ts # Environment validation
│ ├── query-keys.ts # React Query key factory
│ └── validators/ # Zod validation schemas
├── modules/ # Feature modules
│ ├── auth/
│ └── users/
├── providers/ # App providers
├── services/ # Axios instances and token service
└── utils/ # Utility functions
| Type | Convention | Example |
|---|---|---|
| Files | snake_case | user_profile.tsx |
| Components | PascalCase | UserProfile |
| Hooks | camelCase with prefix | useUserProfile |
| Types | PascalCase | AuthUser |
| Validators | camelCase | signinValidator |
Public pages accessible by everyone regardless of authentication status.
Pages requiring authentication. Unauthenticated users are redirected to /auth/login with a redirect query parameter.
Authentication pages (login, register, forgot-password, reset-password, verify-email). Authenticated users are redirected to /dashboard or the redirect query parameter.
Use the query key factory for consistent query keys:
import { queryKeys } from "@/libs/query-keys";
queryKeys.users.profile() // ["users", "profile"]
queryKeys.users.detail("123") // ["users", "detail", "123"]Extend for new modules:
export const queryKeys = {
users: { ... },
posts: {
all: ["posts"] as const,
list: () => [...queryKeys.posts.all, "list"] as const,
detail: (id: string) => [...queryKeys.posts.all, "detail", id] as const,
},
};Tokens are stored in cookies with the following configuration:
| Option | Value | Notes |
|---|---|---|
httpOnly |
false |
Accessible to JavaScript for client-side auth checks |
secure |
true (production) |
HTTPS only in production |
sameSite |
lax |
CSRF protection |
path |
/ |
Available to all routes |
Token Lifetimes (configurable via environment variables):
- Access Token:
ACCESS_TOKEN_MAX_AGE(default: 900 seconds / 15 minutes) - Refresh Token:
REFRESH_TOKEN_MAX_AGE(default: 604800 seconds / 7 days)
Note: Cookies are intentionally NOT httpOnly to allow client-side JavaScript to read tokens for auth state management. The access token is included in the
Authorizationheader for API requests.
Protected routes prefetch user profile on the server:
// src/app/(protected)/layout.tsx
export default async function ProtectedLayout({ children }) {
const queryClient = new QueryClient();
const user = await getUserProfile();
if (user) {
queryClient.setQueryData(queryKeys.users.profile(), user);
}
return (
<HydrationProvider state={dehydrate(queryClient)}>
<EmailVerifiedGuard>{children}</EmailVerifiedGuard>
</HydrationProvider>
);
}This eliminates loading states on initial page load.
Route protection is handled server-side in src/middleware.ts. Protected and auth routes are defined in arrays at the top of the file.
- Login/Register: Backend returns tokens → stored via
/api/auth/tokenPOST - API Requests: Cookies sent automatically with
withCredentials: true - Token Refresh: On 401 response,
/api/auth/refreshis called automatically - Logout: Tokens cleared via
/api/auth/tokenDELETE
Edit src/middleware.ts:
const PROTECTED_ROUTES = ["/dashboard", "/settings", "/your-new-route"];Each module follows a layered architecture:
modules/[module-name]/
├── api/
│ └── [module]API.ts # API endpoint functions
├── services/
│ └── use[Action].ts # TanStack Query hooks
├── ui/
│ └── [Component].tsx # UI components
└── types.ts # Module-specific types
- Create module folder:
src/modules/[module-name]/ - Add API layer:
// src/modules/posts/api/postsAPI.ts
import axiosInstance from "@/services";
export const postsAPI = {
getAll: async () => {
const response = await axiosInstance.get("/posts");
return response.data;
},
getById: async (id: string) => {
const response = await axiosInstance.get(`/posts/${id}`);
return response.data;
},
create: async (data: CreatePostData) => {
const response = await axiosInstance.post("/posts", data);
return response.data;
},
};- Add query keys to the factory:
// src/libs/query-keys.ts
export const queryKeys = {
// ... existing keys
posts: {
all: ["posts"] as const,
list: () => [...queryKeys.posts.all, "list"] as const,
detail: (id: string) => [...queryKeys.posts.all, "detail", id] as const,
},
};- Add service hooks:
// src/modules/posts/services/usePosts.ts
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/libs/query-keys";
import { postsAPI } from "../api/postsAPI";
export const usePosts = () => {
return useQuery({
queryKey: queryKeys.posts.list(),
queryFn: postsAPI.getAll,
});
};- Add mutation hooks:
// src/modules/posts/services/useCreatePost.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/libs/query-keys";
import { postsAPI } from "../api/postsAPI";
export const useCreatePost = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: postsAPI.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.posts.all });
},
});
};- Add types:
// src/modules/posts/types.ts
export type Post = {
id: string;
title: string;
content: string;
created_at: string;
};
export type CreatePostData = {
title: string;
content: string;
};Validators use Zod v4 and are located in src/libs/validators/.
// src/libs/validators/create_post.ts
import { z } from "zod";
export const createPostValidator = z.object({
title: z.string({ error: "Title is required" }).min(1, "Title is required"),
content: z.string({ error: "Content is required" }).min(1, "Content is required"),
});
export type CreatePostFormData = z.infer<typeof createPostValidator>;Usage with React Hook Form:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { createPostValidator, CreatePostFormData } from "@/libs/validators/create_post";
const form = useForm<CreatePostFormData>({
resolver: zodResolver(createPostValidator),
});RHF-adapted components are in src/components/base/_rhf/. They wrap base components with useController.
Available components:
RHFInput- Text inputRHFPasswordInput- Password input with visibility toggleRHFTextarea- TextareaRHFCheckbox- CheckboxRHFNumberInput- Number input with increment/decrement
Usage:
import { RHFInput } from "@/components/base/_rhf/rhf-input";
<RHFInput
name="email"
control={form.control}
label="Email"
placeholder="Enter your email"
/>Two axios instances are available in src/services/index.ts:
axiosInstance(default export): For authenticated requests. Includes 401 interceptor that automatically refreshes tokens.authAxiosInstance: For auth endpoints (login, register). No token refresh interceptor.
Theme toggling uses next-themes with class-based switching:
- Light mode:
light-modeclass - Dark mode:
dark-modeclass
ThemeToggler component available at src/components/base/theme-toggler/.
src/app/error.tsx- Catches errors in route segmentssrc/app/global-error.tsx- Catches errors in root layoutsrc/components/error-boundary.tsx- Client-side error boundary
Use getErrorMessage for consistent error messages:
import { getErrorMessage } from "@/modules/common/libs/getErrorMessage";
onError: (error) => {
toast.error(getErrorMessage(error, "Failed to save"));
}- No comments in code
- Prefer editing existing files over creating new ones
- Keep components focused and single-purpose
- Use TypeScript strict mode
- Run
pnpm lintbefore committing
This template uses Untitled UI React components.