A production-ready React template with authentication, protected routes, and modular architecture.
- React 19
- Vite 7
- TypeScript 5
- TanStack Query (React Query)
- React Router DOM 7
- React Hook Form + Zod
- Axios
- Tailwind CSS 4
- ESLint 9 + Prettier
- Untitled UI React
pnpm install
pnpm devOpen http://localhost:5173 with your browser to see the result.
pnpm dev # Start development server
pnpm build # Build for production
pnpm preview # Preview production build
pnpm lint # Run ESLint with auto-fix
pnpm lint:check # Run ESLint without auto-fix (CI)
pnpm format # Format with Prettier
pnpm format:check # Check formatting (CI)
pnpm type-check # TypeScript type checking
pnpm generate:module # Generate a new CRUD moduleEnvironment variables are validated at startup using Zod. If required variables are missing or invalid, the app will fail fast with helpful error messages.
Create a .env.local file:
VITE_API_BASE_URL=http://localhost:4000/apiTo add new environment variables:
- Add to the schema in
src/libs/env.ts - Add to
.env.example
import { env, isDev, isProd } from '@/libs/env';
const apiUrl = env.VITE_API_BASE_URL; // Type-safe, validatedpnpm install # Automatically sets up husky via "prepare" scriptPre-commit:
- Runs lint-staged (ESLint + Prettier on staged files)
- Fixed files are automatically re-staged
- Runs type-check on entire codebase
Pre-push:
- Runs full build to catch build errors before pushing
CI runs on every push/PR to main:
- Lint check
- Format check
- Type check
- Build
See .github/workflows/ci.yml for configuration.
Build and run with Docker Compose:
docker compose up --buildOr build manually:
docker build -t frontend-template --build-arg VITE_API_BASE_URL=http://localhost:4000/api .
docker run -p 3000:80 frontend-templatesrc/
├── components/
│ ├── application/
│ │ ├── data-table/ # Generic DataTable component
│ │ ├── error-boundary/ # ErrorBoundary, QueryErrorBoundary
│ │ ├── app-navigation/ # Sidebar and header navigation
│ │ └── modals/ # Modal components
│ ├── base/
│ │ ├── _rhf/ # React Hook Form adapted components
│ │ └── [component]/ # Base UI components
│ ├── guards/ # Route guards (AuthGuard, GuestGuard, RoleGuard)
│ └── page-loading.tsx # Full page loading
├── contexts/ # React contexts
├── hooks/ # Custom hooks
├── layouts/ # Layout components
├── libs/
│ ├── storage/ # Token storage utilities
│ ├── validators/ # Zod validation schemas
│ ├── env.ts # Environment configuration
│ └── usePageMetadata.ts # Page title/description hook
├── modules/ # Feature modules
│ ├── auth/ # Authentication module
│ ├── users/ # Users CRUD module
│ └── common/ # Shared utilities and types
├── pages/ # Page components
├── providers/ # App providers
├── routes/ # Route configuration
├── services/ # Axios instances
├── styles/ # Global CSS
└── utils/ # Utility functions
scripts/
├── generate-module.cjs # Module generator script
└── templates/ # Generator templates
├── module/ # Module file templates
└── page/ # Page file templates
| Type | Convention | Example |
|---|---|---|
| Module UI components | PascalCase | SigninForm.tsx |
| Page components | PascalCase | DashboardPage.tsx |
| Module type files | PascalCase | AuthUser.ts, SigninRequest.ts |
| Other files | kebab-case | use-user-profile.ts, auth-api.ts |
| Component exports | PascalCase | UserProfile |
| Hooks | camelCase with prefix | useUserProfile |
| Types | PascalCase | AuthUser |
| Validators | camelCase | signinValidator |
Routes are defined using createBrowserRouter with object-based configuration in src/routes/index.tsx:
export const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
children: [
{ index: true, element: <HomePage /> },
{
path: "auth",
element: <GuestGuard><AuthLayout /></GuestGuard>,
children: [
{ path: "login", element: <LoginPage /> },
{ path: "register", element: <RegisterPage /> },
],
},
{
path: "dashboard",
element: <AuthGuard><EmailVerifiedGuard><DashboardLayout /></EmailVerifiedGuard></AuthGuard>,
children: [
{ index: true, element: <DashboardPage /> },
],
},
],
},
]);Protects routes requiring authentication. Redirects to /auth/login if not authenticated.
For auth pages (login, register). Redirects to /dashboard if already authenticated.
Ensures user has verified email before accessing protected content.
Restricts access based on user role hierarchy.
import { Role } from "@/modules/auth";
// In route configuration
{
path: "users",
element: (
<RoleGuard minimumRole={Role.Admin} pageLevel>
<UsersPage />
</RoleGuard>
),
}
// Inline usage
<RoleGuard minimumRole={Role.Admin}>
<AdminOnlyButton />
</RoleGuard>The auth module provides a role system with hierarchy:
// Role hierarchy (highest to lowest)
enum Role {
Superadmin = "superadmin",
Admin = "admin",
User = "user",
}
// useRole hook for checking permissions
import { useRole, Role } from "@/modules/auth";
const { role, isAtLeast, isExactly } = useRole();
isAtLeast(Role.Admin); // true if Admin or Superadmin
isExactly(Role.User); // true only if User
// Role labels and badge colors for UI
import { ROLE_LABELS, ROLE_BADGE_COLORS } from "@/modules/auth";
<Badge color={ROLE_BADGE_COLORS[user.role]}>
{ROLE_LABELS[user.role]}
</Badge>The @/modules/common module provides reusable factories and hooks.
Factory for creating standard CRUD API methods:
import { createCrudAPI } from '@/modules/common';
const crudAPI = createCrudAPI<Entity, CreateDTO, UpdateDTO, ListParams>({
axios: axiosInstance,
endpoint: '/entities',
});
// Returns: { getAll, getById, create, update, delete }Factory for consistent query key patterns:
import { createQueryKeys } from '@/modules/common';
const entityKeys = createQueryKeys<ListParams>('entities');
// Returns:
// entityKeys.all → ["entities"]
// entityKeys.lists() → ["entities", "list"]
// entityKeys.list(params)→ ["entities", "list", params]
// entityKeys.details() → ["entities", "detail"]
// entityKeys.detail(id) → ["entities", "detail", id]Generic hook for managing table filters via URL search params:
import { useTableFilters } from '@/modules/common';
interface MyFilters extends BaseTableFilters {
search: string;
status: string;
}
const { filters, setFilter, setPage, resetFilters, hasActiveFilters } = useTableFilters<MyFilters>({
defaults: { page: 1, limit: 10, search: '', status: '' },
resetPageOn: ['search', 'status'], // Reset to page 1 when these change
});Generic data table with loading states, pagination, and empty states:
import { DataTable, type ColumnDef } from "@/components/application/data-table/data-table";
const columns: ColumnDef<User>[] = [
{
id: "name",
header: "Name",
isRowHeader: true,
cell: (user) => <span>{user.name}</span>,
},
{
id: "actions",
header: "",
cell: (user) => <RowActions user={user} />,
},
];
<DataTable
data={users}
columns={columns}
isLoading={isLoading}
meta={meta}
page={filters.page}
onPageChange={setPage}
title="Users"
badge={meta?.total}
hasActiveFilters={hasActiveFilters}
emptyState={{
icon: Users01,
title: "No users found",
description: "There are no users to display.",
filteredDescription: "No users match your filters.",
}}
headerContent={<Filters />}
/>Catch and handle errors gracefully:
import { ErrorBoundary } from "@/components/application/error-boundary/error-boundary";
import { QueryErrorBoundary } from "@/components/application/error-boundary/query-error-boundary";
// For general React errors
<ErrorBoundary>
<MyComponent />
</ErrorBoundary>
// For React Query errors (with retry integration)
<QueryErrorBoundary>
<DataFetchingComponent />
</QueryErrorBoundary>Set page title and meta description for SEO:
import { usePageMetadata } from "@/libs/usePageMetadata";
export const UsersPage = () => {
usePageMetadata({
title: "Users",
description: "Manage system users" // optional
});
return <div>...</div>;
};Configure sidebar items in src/layouts/DashboardLayout/Sidebar.tsx:
import { Home03, Users01, Folder } from '@untitledui/icons';
const navItems: (NavItemType | NavItemDividerType)[] = [
{
label: 'Dashboard',
href: '/dashboard',
icon: Home03,
},
{
label: 'Users',
href: '/dashboard/users',
icon: Users01,
},
{
divider: true, // Section divider
label: 'Settings', // Divider label (shown when expanded)
},
{
label: 'Settings',
href: '/dashboard/settings',
icon: Settings01,
},
];The sidebar supports collapsed mode with icon-only navigation and horizontal dividers.
Query keys are defined in the module's constants/ folder:
// src/modules/users/constants/query-keys.ts
export const userQueryKeys = {
all: ['users'] as const,
profile: () => [...userQueryKeys.all, 'profile'] as const,
detail: (id: string) => [...userQueryKeys.all, 'detail', id] as const,
list: () => [...userQueryKeys.all, 'list'] as const,
};Usage in services:
import { userQueryKeys } from '../constants';
export const useUserProfile = () => {
return useQuery({
queryKey: userQueryKeys.profile(),
queryFn: usersAPI.getMe,
});
};Tokens are stored in localStorage:
import { tokenStorage } from '@/libs/storage';
tokenStorage.setTokens(accessToken, refreshToken);
tokenStorage.getAccessToken();
tokenStorage.getRefreshToken();
tokenStorage.clearTokens();
tokenStorage.hasValidToken();- Login/Register: Backend returns tokens → stored in localStorage
- API Requests: Token added via axios interceptor (
Authorization: Bearer ${token}) - Token Refresh: On 401 response, refresh endpoint is called automatically
- Logout: Tokens cleared from localStorage
Edit src/routes/index.tsx:
{
path: "your-route",
element: (
<AuthGuard>
<YourLayout />
</AuthGuard>
),
children: [...],
}Each module follows a layered architecture:
modules/[module-name]/
├── api/
│ └── [module]API.ts # API endpoint functions
├── constants/
│ ├── query-keys.ts # Query key factory
│ └── index.ts # Constants exports
├── libs/
│ ├── use[Module]Filters.ts # Table filter hooks
│ └── index.ts # Libs exports
├── services/
│ └── use[Action].ts # TanStack Query hooks
├── types/
│ ├── [TypeName].ts # Individual type files (PascalCase)
│ └── index.ts # Types exports
├── ui/
│ ├── [Module]Table.tsx # Table component
│ ├── [Module]Filters.tsx # Filters component
│ ├── Create[Entity]Modal.tsx
│ ├── Edit[Entity]Modal.tsx
│ ├── Delete[Entity]Dialog.tsx
│ └── index.ts # UI exports
└── index.ts # Public module exports
The fastest way to create a new CRUD module:
pnpm generate:module <module-name>Examples:
pnpm generate:module product # Creates: products module
pnpm generate:module category # Creates: categories module (handles irregular plurals)
pnpm generate:module person # Creates: people moduleWhat gets generated:
src/modules/<plural>/- Complete module with types, API, services, libs, UIsrc/pages/<Plural>Page.tsx- Page with CRUD functionality- Route at
/dashboard/<plural> - Sidebar navigation item
Generated UI components:
<Plural>Table- DataTable with columns and row actions<Plural>Filters- Search input with debounceCreate<Singular>Modal- Create form with validationEdit<Singular>Modal- Edit form with pre-populated dataDelete<Singular>Dialog- Confirmation dialog
After generating, customize:
- Update types in
src/modules/<plural>/types/ - Add columns to the table in
ui/<Plural>Table.tsx - Add form fields to modals
- Run
pnpm type-checkto verify
For custom modules, create the folder structure manually:
// 1. Types
// src/modules/posts/types/Post.ts
export type Post = {
id: string;
title: string;
content: string;
};
// 2. Query Keys (or use createQueryKeys factory)
// src/modules/posts/constants/query-keys.ts
import { createQueryKeys } from '@/modules/common';
export const postQueryKeys = createQueryKeys<PostsParams>('posts');
// 3. API (or use createCrudAPI factory)
// src/modules/posts/api/postsAPI.ts
import { createCrudAPI } from '@/modules/common';
export const postsAPI = createCrudAPI<Post, CreatePostRequest, UpdatePostRequest, PostsParams>({
axios: axiosInstance,
endpoint: '/posts',
});
// 4. Service hooks
// src/modules/posts/services/usePosts.ts
export const usePosts = (params: PostsParams) => {
return useQuery({
queryKey: postQueryKeys.list(params),
queryFn: () => postsAPI.getAll(params),
});
};Validators use Zod 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- Checkbox
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 class-based switching via ThemeProvider:
- Light mode:
light-modeclass - Dark mode:
dark-modeclass - System mode: follows OS preference
import { useTheme } from '@/providers/theme-provider';
const { theme, setTheme, cycleTheme } = useTheme();
// theme: "light" | "dark" | "system"Use getErrorMessage for consistent error messages:
import { getErrorMessage } from '@/modules/common';
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to save'));
};Generic pagination types are available in @/modules/common:
import { PaginatedResponse, PaginationMeta } from '@/modules/common';
// PaginationMeta structure
interface PaginationMeta {
total: number;
page: number;
limit: number;
totalPages: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
}
// Usage with your data type
type PostsResponse = PaginatedResponse<Post>;
// Resolves to: { data: Post[]; meta: PaginationMeta; }- Prefer editing existing files over creating new ones
- Keep components focused and single-purpose
- Use TypeScript strict mode
- Run
pnpm lintbefore committing - DO NOT write comments in code - Code should be self-documenting through clear naming and structure
Rules and patterns for AI-assisted development.
Imports must follow this order (enforced by ESLint):
// 1. React and external libraries
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { z } from 'zod';
// 2. Internal modules (@/modules/*)
import { useUsers } from '@/modules/users';
import { Role } from '@/modules/auth';
// 3. Components (@/components/*)
import { DataTable } from '@/components/application/data-table/data-table';
import { Button } from '@/components/base/buttons/button';
// 4. Other internal imports (@/*)
import { usePageMetadata } from '@/libs/usePageMetadata';
import { cx } from '@/utils/cx';
// 5. Relative imports (../ and ./)
import { usePostsFilters } from '../libs';
import type { Post } from '../types';When to use Module Generator:
- Creating a new CRUD resource with list/create/edit/delete
- Standard REST API endpoints
- Needs table, filters, and modals
When to create manually:
- Non-CRUD features (auth, settings, dashboards)
- Custom UI patterns
- Complex business logic
When to use factories:
createCrudAPI→ Standard REST endpoints (GET, POST, PUT, DELETE)createQueryKeys→ Any module with React QueryuseTableFilters→ Any paginated list with URL-synced filters
When to add to common module:
- Utility is used by 2+ modules
- Generic enough to be reusable
- Not tied to specific business logic
// ❌ Don't use axios directly in components
const data = await axios.get("/users");
// ✅ Use module API layer
const data = await usersAPI.getUsers();
// ❌ Don't define query keys inline
useQuery({ queryKey: ["users", "list"] });
// ✅ Use query key factory
useQuery({ queryKey: userQueryKeys.list() });
// ❌ Don't create files outside module structure
src/helpers/userHelpers.ts
// ✅ Keep in module
src/modules/users/libs/userHelpers.ts
// ❌ Don't mix API calls with UI logic
const Component = () => {
const response = await axiosInstance.get("/users");
};
// ✅ Use service hooks
const Component = () => {
const { data } = useUsers();
};
// ❌ Don't duplicate types
type User = { id: string; name: string };
type UserData = { id: string; name: string };
// ✅ Reuse and extend
type UserWithPosts = User & { posts: Post[] };
// ❌ Don't hardcode strings for roles
if (user.role === "admin")
// ✅ Use Role enum
if (user.role === Role.Admin)
// ❌ Don't forget error boundaries for data fetching
<UsersTable />
// ✅ Wrap with QueryErrorBoundary
<QueryErrorBoundary>
<UsersTable />
</QueryErrorBoundary>
// ❌ Don't skip page metadata
export const UsersPage = () => { ... }
// ✅ Always set page title
export const UsersPage = () => {
usePageMetadata({ title: "Users" });
...
}
// ❌ Don't write comments in code
const getUser = () => { // Gets user data
return usersAPI.getUsers();
};
// ✅ Code should be self-documenting
const getUser = () => {
return usersAPI.getUsers();
};// Use `type` for object shapes and unions
type User = {
id: string;
name: string;
};
type Status = "active" | "inactive";
// Use `interface` for component props (enables declaration merging)
interface ButtonProps {
children: ReactNode;
onClick?: () => void;
}
// Request/Response types use PascalCase with suffix
type CreateUserRequest = { ... };
type UpdateUserRequest = { ... };
type UsersResponse = PaginatedResponse<User>;
// Filter types extend BaseTableFilters
interface UsersFilters extends BaseTableFilters {
search: string;
role: Role | "";
}Page component structure:
export const EntityPage = () => {
// 1. Page metadata
usePageMetadata({ title: "Entities" });
// 2. State for modals
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [editing, setEditing] = useState<Entity | null>(null);
const [deleting, setDeleting] = useState<Entity | null>(null);
// 3. Render
return (
<PageWrapper>
<PageHeader>...</PageHeader>
<PageContent>
<QueryErrorBoundary>
<EntityTable onEdit={setEditing} onDelete={setDeleting} />
</QueryErrorBoundary>
<CreateEntityModal ... />
<EditEntityModal ... />
<DeleteEntityDialog ... />
</PageContent>
</PageWrapper>
);
};Table component structure:
export const EntityTable = ({ onEdit, onDelete }: Props) => {
// 1. Filters hook
const { filters, setPage, setSearch, hasActiveFilters } = useEntityFilters();
// 2. Data fetching
const { data, isLoading } = useEntities(filters);
// 3. Column definitions (memoized)
const columns = useMemo(() => [...], [onEdit, onDelete]);
// 4. Render DataTable
return <DataTable ... />;
};Modal component structure:
export const CreateEntityModal = ({ isOpen, onClose }: Props) => {
// 1. Mutation hook
const { mutate, isPending } = useCreateEntity();
// 2. Form setup
const { control, handleSubmit, reset } = useForm({ resolver: zodResolver(schema) });
// 3. Submit handler
const onSubmit = (data) => {
mutate(data, { onSuccess: () => { reset(); onClose(); } });
};
// 4. Render modal with form
return <DialogTrigger isOpen={isOpen} ...>...</DialogTrigger>;
};| What | Convention | Example |
|---|---|---|
| Page | PascalCase + Page |
UsersPage.tsx |
| Module UI | PascalCase |
UsersTable.tsx, CreateUserModal.tsx |
| Hooks | camelCase + use | useUsers.ts, useCreateUser.ts |
| API | camelCase + API | usersAPI.ts |
| Types | PascalCase | User.ts, CreateUserRequest.ts |
| Utils/libs | camelCase | useUsersFilters.ts |
| Constants | kebab-case | query-keys.ts |
This template uses Untitled UI React components.