├── .env.example
├── .eslintrc.js
├── .github
├── issue-branch.yml
├── pull_request_template.md
└── workflows
│ ├── create-branch.yml
│ ├── issue-autolink.yml
│ ├── lint.yml
│ └── release-please.yml
├── .gitignore
├── .husky
├── commit-msg
├── post-merge
└── pre-commit
├── .npmrc
├── .prettierignore
├── .prettierrc.js
├── .vscode
├── css.code-snippets
├── extensions.json
├── settings.json
└── typescriptreact.code-snippets
├── CHANGELOG.md
├── README.md
├── commitlint.config.js
├── jest.config.js
├── jest.setup.js
├── next-sitemap.config.js
├── next.config.js
├── package.json
├── postcss.config.js
├── public
├── favicon.ico
├── favicon
│ ├── android-chrome-192x192.png
│ ├── android-chrome-512x512.png
│ ├── apple-touch-icon.png
│ ├── favicon-16x16.png
│ ├── favicon-32x32.png
│ ├── favicon.ico
│ └── site.webmanifest
├── fonts
│ └── inter-var-latin.woff2
├── images
│ ├── app-page-tsx.png
│ ├── cover.png
│ ├── new-tab.png
│ ├── next-mui-folders.png
│ └── og.jpg
└── svg
│ ├── Logo.svg
│ └── Vercel.svg
├── src
├── __mocks__
│ └── svg.tsx
├── app
│ ├── api
│ │ └── test
│ │ │ └── route.ts
│ ├── error.tsx
│ ├── layout.tsx
│ ├── not-found.tsx
│ └── page.tsx
├── components
│ ├── Homepage.tsx
│ ├── homepage
│ │ ├── BottomLinks.tsx
│ │ ├── DisplayRandomPicture.tsx
│ │ ├── PageFooter.tsx
│ │ ├── ReactActionForm.tsx
│ │ └── ReactHookForm.tsx
│ └── shared
│ │ ├── ClientSideWrapper.tsx
│ │ ├── ServerDateTime.tsx
│ │ └── SubmitButton.tsx
├── constants
│ ├── config.ts
│ ├── context.ts
│ ├── env.ts
│ └── index.ts
├── hooks
│ ├── useAlertBar.tsx
│ ├── useClientContext.test.tsx
│ ├── useClientContext.tsx
│ ├── useConfirmDialog.tsx
│ └── useSharedUtilContext.tsx
├── styles
│ └── index.ts
├── types
│ └── index.ts
└── utils
│ ├── __tests__
│ └── og.test.ts
│ └── shared
│ ├── console-log.ts
│ ├── get-api-response.ts
│ └── og.ts
├── tsconfig.json
├── vercel.json
└── yarn.lock
/.env.example:
--------------------------------------------------------------------------------
1 | # !STARTERCONF Duplicate this to .env.local
2 |
3 | # DEVELOPMENT TOOLS
4 | # Ideally, don't add them to production deployment envs
5 | # !STARTERCONF Change to true if you want to log data
6 | NEXT_PUBLIC_SHOW_LOGGER="false"
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2021: true,
5 | node: true,
6 | },
7 | plugins: ['@typescript-eslint', 'simple-import-sort', 'unused-imports'],
8 | extends: [
9 | 'eslint:recommended',
10 | 'next',
11 | 'next/core-web-vitals',
12 | 'plugin:@typescript-eslint/recommended',
13 | 'prettier',
14 | ],
15 | rules: {
16 | 'no-unused-vars': 'off',
17 | 'no-console': 'warn',
18 | '@typescript-eslint/explicit-module-boundary-types': 'off',
19 | 'react/no-unescaped-entities': 'off',
20 |
21 | 'react/display-name': 'off',
22 | 'react/jsx-curly-brace-presence': [
23 | 'warn',
24 | { props: 'never', children: 'never' },
25 | ],
26 |
27 | //#region //*=========== Unused Import ===========
28 | '@typescript-eslint/no-unused-vars': 'off',
29 | 'unused-imports/no-unused-imports': 'warn',
30 | 'unused-imports/no-unused-vars': [
31 | 'warn',
32 | {
33 | vars: 'all',
34 | varsIgnorePattern: '^_',
35 | args: 'after-used',
36 | argsIgnorePattern: '^_',
37 | },
38 | ],
39 | //#endregion //*======== Unused Import ===========
40 |
41 | //#region //*=========== Import Sort ===========
42 | 'simple-import-sort/exports': 'warn',
43 | 'simple-import-sort/imports': [
44 | 'warn',
45 | {
46 | groups: [
47 | // ext library & side effect imports
48 | ['^@?\\w', '^\\u0000'],
49 | // {s}css files
50 | ['^.+\\.s?css$'],
51 | // Lib and hooks
52 | ['^@/lib', '^@/hooks'],
53 | // static data
54 | ['^@/data'],
55 | // components
56 | ['^@/components', '^@/container'],
57 | // zustand store
58 | ['^@/store'],
59 | // Other imports
60 | ['^@/'],
61 | // relative paths up until 3 level
62 | [
63 | '^\\./?$',
64 | '^\\.(?!/?$)',
65 | '^\\.\\./?$',
66 | '^\\.\\.(?!/?$)',
67 | '^\\.\\./\\.\\./?$',
68 | '^\\.\\./\\.\\.(?!/?$)',
69 | '^\\.\\./\\.\\./\\.\\./?$',
70 | '^\\.\\./\\.\\./\\.\\.(?!/?$)',
71 | ],
72 | ['^@/types'],
73 | // other that didnt fit in
74 | ['^'],
75 | ],
76 | },
77 | ],
78 | //#endregion //*======== Import Sort ===========
79 | },
80 | globals: {
81 | React: true,
82 | JSX: true,
83 | },
84 | };
85 |
--------------------------------------------------------------------------------
/.github/issue-branch.yml:
--------------------------------------------------------------------------------
1 | # https://github.com/robvanderleek/create-issue-branch#option-2-configure-github-action
2 |
3 | # ex: i4-lower_camel_upper
4 | branchName: 'i${issue.number}-${issue.title,}'
5 | branches:
6 | - label: epic
7 | skip: true
8 | - label: debt
9 | skip: true
10 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | # Description & Technical Solution
2 |
3 | Describe problems, if any, clearly and concisely.
4 | Summarize the impact to the system.
5 | Please also include relevant motivation and context.
6 | Please include a summary of the technical solution and how it solves the problem.
7 |
8 | # Checklist
9 |
10 | - [ ] I have commented my code, particularly in hard-to-understand areas.
11 | - [ ] Already rebased against main branch.
12 |
13 | # Screenshots
14 |
15 | Provide screenshots or videos of the changes made if any.
16 |
--------------------------------------------------------------------------------
/.github/workflows/create-branch.yml:
--------------------------------------------------------------------------------
1 | name: Create Branch from Issue
2 |
3 | on:
4 | issues:
5 | types: [assigned]
6 |
7 | jobs:
8 | create_issue_branch_job:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Create Issue Branch
12 | uses: robvanderleek/create-issue-branch@main
13 | env:
14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
15 |
--------------------------------------------------------------------------------
/.github/workflows/issue-autolink.yml:
--------------------------------------------------------------------------------
1 | name: 'Issue Autolink'
2 | on:
3 | pull_request:
4 | types: [opened]
5 |
6 | jobs:
7 | issue-links:
8 | runs-on: ubuntu-latest
9 | permissions:
10 | pull-requests: write
11 | steps:
12 | - uses: tkt-actions/add-issue-links@v1.8.1
13 | with:
14 | repo-token: '${{ secrets.GITHUB_TOKEN }}'
15 | branch-prefix: 'i'
16 | resolve: 'true'
17 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: Code Check
2 | on:
3 | push:
4 | branches:
5 | - main
6 | pull_request: {}
7 |
8 | concurrency:
9 | group: ${{ github.job }}-${{ github.ref }}
10 | cancel-in-progress: true
11 |
12 | jobs:
13 | lint:
14 | name: ⬣ ESLint, ʦ TypeScript, 💅 Prettier, and 🃏 Test
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: ⬇️ Checkout repo
18 | uses: actions/checkout@v2
19 |
20 | - name: ⎔ Setup node
21 | uses: actions/setup-node@v3
22 | with:
23 | node-version: 18
24 |
25 | - name: 📥 Download deps
26 | uses: u0reo/npm-install@fix/restore-failure
27 | with:
28 | useRollingCache: true
29 |
30 | - name: 🔬 Lint
31 | run: yarn lint:strict
32 |
33 | - name: 🔎 Type check
34 | run: yarn typecheck
35 |
36 | - name: 💅 Prettier check
37 | run: yarn format:check
38 |
39 | - name: 🃏 Run jest
40 | run: yarn test
41 |
--------------------------------------------------------------------------------
/.github/workflows/release-please.yml:
--------------------------------------------------------------------------------
1 | name: release-please
2 | on:
3 | # !STARTERCONF Choose your preferred event
4 | # !Option 1: Manual Trigger from GitHub
5 | workflow_dispatch:
6 | # !Option 2: Release on every push on main branch
7 | # push:
8 | # branches:
9 | # - main
10 | jobs:
11 | release-please:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - uses: google-github-actions/release-please-action@v3
15 | with:
16 | release-type: node
17 | package-name: release-please-action
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env
30 | .env*.local
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
39 | # next-sitemap
40 | robots.txt
41 | sitemap.xml
42 | sitemap-*.xml
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx --no-install commitlint --edit "$1"
5 |
--------------------------------------------------------------------------------
/.husky/post-merge:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | yarn
5 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx lint-staged
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | v18.14.0
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | .next
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env.local
30 | .env.development.local
31 | .env.test.local
32 | .env.production.local
33 |
34 | # vercel
35 | .vercel
36 |
37 | # changelog
38 | CHANGELOG.md
39 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | arrowParens: 'always',
3 | singleQuote: true,
4 | jsxSingleQuote: true,
5 | tabWidth: 2,
6 | semi: true,
7 | };
8 |
--------------------------------------------------------------------------------
/.vscode/css.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | "Region CSS": {
3 | "prefix": "regc",
4 | "body": [
5 | "/* #region /**=========== ${1} =========== */",
6 | "$0",
7 | "/* #endregion /**======== ${1} =========== */"
8 | ]
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | // Tailwind CSS Intellisense
4 | "bradlc.vscode-tailwindcss",
5 | "esbenp.prettier-vscode",
6 | "dbaeumer.vscode-eslint",
7 | "aaron-bond.better-comments"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "css.validate": false,
3 | "editor.formatOnSave": true,
4 | "editor.tabSize": 2,
5 | "editor.codeActionsOnSave": {
6 | "source.fixAll": "explicit"
7 | },
8 | // Tailwind CSS Autocomplete, add more if used in projects
9 | "tailwindCSS.classAttributes": [
10 | "class",
11 | "className",
12 | "classNames",
13 | "containerClassName"
14 | ],
15 | "typescript.preferences.importModuleSpecifier": "non-relative"
16 | }
17 |
--------------------------------------------------------------------------------
/.vscode/typescriptreact.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | //#region //*=========== React ===========
3 | "import React": {
4 | "prefix": "ir",
5 | "body": ["import * as React from 'react';"]
6 | },
7 | "React.useState": {
8 | "prefix": "us",
9 | "body": [
10 | "const [${1}, set${1/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}] = React.useState<$3>(${2:initial${1/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}})$0"
11 | ]
12 | },
13 | "React.useEffect": {
14 | "prefix": "uf",
15 | "body": ["React.useEffect(() => {", " $0", "}, []);"]
16 | },
17 | "React.useReducer": {
18 | "prefix": "ur",
19 | "body": [
20 | "const [state, dispatch] = React.useReducer(${0:someReducer}, {",
21 | " ",
22 | "})"
23 | ]
24 | },
25 | "React.useRef": {
26 | "prefix": "urf",
27 | "body": ["const ${1:someRef} = React.useRef($0)"]
28 | },
29 | "React Functional Component": {
30 | "prefix": "rc",
31 | "body": [
32 | "import * as React from 'react';\n",
33 | "export default function ${1:${TM_FILENAME_BASE}}() {",
34 | " return (",
35 | "
",
36 | " $0",
37 | "
",
38 | " )",
39 | "}"
40 | ]
41 | },
42 | "React Functional Component with Props": {
43 | "prefix": "rcp",
44 | "body": [
45 | "import * as React from 'react';\n",
46 | "import clsxm from '@/lib/clsxm';\n",
47 | "type ${1:${TM_FILENAME_BASE}}Props= {\n",
48 | "} & React.ComponentPropsWithoutRef<'div'>\n",
49 | "export default function ${1:${TM_FILENAME_BASE}}({className, ...rest}: ${1:${TM_FILENAME_BASE}}Props) {",
50 | " return (",
51 | " ",
52 | " $0",
53 | "
",
54 | " )",
55 | "}"
56 | ]
57 | },
58 | //#endregion //*======== React ===========
59 |
60 | //#region //*=========== Commons ===========
61 | "Region": {
62 | "prefix": "reg",
63 | "scope": "javascript, typescript, javascriptreact, typescriptreact",
64 | "body": [
65 | "//#region //*=========== ${1} ===========",
66 | "${TM_SELECTED_TEXT}$0",
67 | "//#endregion //*======== ${1} ==========="
68 | ]
69 | },
70 | "Region CSS": {
71 | "prefix": "regc",
72 | "scope": "css, scss",
73 | "body": [
74 | "/* #region /**=========== ${1} =========== */",
75 | "${TM_SELECTED_TEXT}$0",
76 | "/* #endregion /**======== ${1} =========== */"
77 | ]
78 | },
79 | //#endregion //*======== Commons ===========
80 |
81 | //#region //*=========== Next.js ===========
82 | "Next Pages": {
83 | "prefix": "np",
84 | "body": [
85 | "import * as React from 'react';\n",
86 | "import Layout from '@/components/layout/Layout';",
87 | "import Seo from '@/components/Seo';\n",
88 | "export default function ${1:${TM_FILENAME_BASE/(^[a-zA-Z])(.*)/${1:/upcase}${2}/}}Page() {",
89 | " return (",
90 | " ",
91 | " \n",
92 | " \n",
93 | " ",
94 | " ",
95 | " $0",
96 | "
",
97 | " ",
98 | " ",
99 | " ",
100 | " )",
101 | "}"
102 | ]
103 | },
104 | "Next API": {
105 | "prefix": "napi",
106 | "body": [
107 | "import { NextApiRequest, NextApiResponse } from 'next';\n",
108 | "export default async function handler(req: NextApiRequest, res: NextApiResponse) {",
109 | " if (req.method === 'GET') {",
110 | " res.status(200).json({ name: 'Bambang' });",
111 | " } else {",
112 | " res.status(405).json({ message: 'Method Not Allowed' });",
113 | " }",
114 | "}"
115 | ]
116 | },
117 | "Get Static Props": {
118 | "prefix": "gsp",
119 | "body": [
120 | "export const getStaticProps = async (context: GetStaticPropsContext) => {",
121 | " return {",
122 | " props: {}",
123 | " };",
124 | "}"
125 | ]
126 | },
127 | "Get Static Paths": {
128 | "prefix": "gspa",
129 | "body": [
130 | "export const getStaticPaths: GetStaticPaths = async () => {",
131 | " return {",
132 | " paths: [",
133 | " { params: { $1 }}",
134 | " ],",
135 | " fallback: ",
136 | " };",
137 | "}"
138 | ]
139 | },
140 | "Get Server Side Props": {
141 | "prefix": "gssp",
142 | "body": [
143 | "export const getServerSideProps = async (context: GetServerSidePropsContext) => {",
144 | " return {",
145 | " props: {}",
146 | " };",
147 | "}"
148 | ]
149 | },
150 | "Infer Get Static Props": {
151 | "prefix": "igsp",
152 | "body": "InferGetStaticPropsType"
153 | },
154 | "Infer Get Server Side Props": {
155 | "prefix": "igssp",
156 | "body": "InferGetServerSidePropsType"
157 | },
158 | "Import useRouter": {
159 | "prefix": "imust",
160 | "body": ["import { useRouter } from 'next/router';"]
161 | },
162 | "Import Next Image": {
163 | "prefix": "imimg",
164 | "body": ["import Image from 'next/image';"]
165 | },
166 | "Import Next Link": {
167 | "prefix": "iml",
168 | "body": ["import Link from 'next/link';"]
169 | },
170 | //#endregion //*======== Next.js ===========
171 |
172 | //#region //*=========== Snippet Wrap ===========
173 | "Wrap with Fragment": {
174 | "prefix": "ff",
175 | "body": ["<>", "\t${TM_SELECTED_TEXT}", ">"]
176 | },
177 | "Wrap with clsx": {
178 | "prefix": "cx",
179 | "body": ["{clsx([${TM_SELECTED_TEXT}$0])}"]
180 | },
181 | "Wrap with clsxm": {
182 | "prefix": "cxm",
183 | "body": ["{clsxm([${TM_SELECTED_TEXT}$0, className])}"]
184 | },
185 | //#endregion //*======== Snippet Wrap ===========
186 |
187 | "Logger": {
188 | "prefix": "lg",
189 | "body": [
190 | "logger({ ${1:${CLIPBOARD}} }, '${TM_FILENAME} line ${TM_LINE_NUMBER}')"
191 | ]
192 | }
193 | }
194 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ReactJs 19.x + NextJs 15.x + MUI 6.x + TypeScript Starter and Boilerplate
2 |
3 |
4 |
2024/2025: 🔋 ReactJs 19.x + NextJs 15.x + MUI 6.x + TypeScript Starter
5 |
The scaffold for NextJs 15.x (App Router), React Hook Form, Material UI(MUI 6.x),Typescript and ESLint, and TypeScript with Absolute Import, Seo, Link component, pre-configured with Husky.
6 |
7 |
With simple example of ReactJs 19.x, NextJs 15.x API, React-hook-form with zod, fetch remote api, 404/500 error pages, MUI SSR usage, Styled component, MUI AlertBar, MUI confirmation dialog, Loading button, Client-side component & React Context update hook
8 |
9 | 🚘🚘🚘 [**Click here to see an online demo**](https://mui-nextjs-ts.vercel.app) 🚘🚘🚘
10 |
11 | If you prefer Tailwind css, check this: [Tailwind-CSS-Version](https://github.com/theodorusclarence/ts-nextjs-tailwind-starter)
12 |
13 |
14 |
15 | ## Demo
16 |
17 | [ ](https://mui-nextjs-ts.vercel.app)
18 |
19 | 🚘🚘🚘 [**Click here to see an online demo**](https://mui-nextjs-ts.vercel.app) 🚘🚘🚘
20 |
21 | ## Clone this repository for React 19.x with NextJs 15.x or React 18.x with NextJs 14.x
22 |
23 | - Clone & install React19-Next15-MUI6-TS-Starter:
24 | - `git clone -b react19-nextjs15 https://github.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter.git react19-nextjs15-mui6-ts-starter && cd react19-nextjs15-mui6-ts-starter && yarn install && yarn dev -p 3005`
25 | - Open
26 | - Note: React 19 is not released yet, there are some warnings in the console, please ignore them.
27 | - Clone & install React18-Next14-MUI5-TS-Starter:
28 | - `git clone -b nextjs14 https://github.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter.git react18-nextjs14-mui5-ts-starter && cd react18-nextjs14-mui5-ts-starter && yarn install && yarn dev -p 3005`
29 | - Open
30 |
31 | ## Features
32 |
33 | This repository is 🔋 battery packed with:
34 |
35 | - ⚡️ Next.js 15.x with App Router
36 | - ⚛️ React 19.x
37 | - ✨ TypeScript
38 | - 💨 Material UI — Ready to use Material Design components [check here for the usage](https://mui.com/material-ui/getting-started/usage/)
39 | - 🎨 React Hook Form — Performant, flexible and extensible forms with easy-to-use validation
40 | - ⏰ Day.js — A modern day JavaScript Date Library
41 | - 🔥 Utils: getApiResponse - consoleLog
42 | - 🃏 Jest — Configured for unit testing
43 | - 📈 Absolute Import and Path Alias — Import components using `@/` prefix
44 | - 📏 ESLint — Find and fix problems in your code, also will **auto sort** your imports
45 | - 💖 Prettier — Format your code consistently
46 | - 🐶 Husky & Lint Staged — Run scripts on your staged files before they are committed
47 | - 🤖 Conventional Commit Lint — Make sure you & your teammates follow conventional commit
48 | - ⏰ Release Please — Generate your changelog by activating the `release-please` workflow
49 | - 👷 Github Actions — Lint your code on PR
50 | - 🚘 Automatic Branch and Issue Autolink — Branch will be automatically created on issue **assign**, and auto linked on PR
51 | - 🔥 Snippets — A collection of useful snippets
52 | - 👀 Open Graph Helper Function — Awesome open graph generated using [og](https://github.com/theodorusclarence/og), fork it and deploy!
53 | - 🗺 Site Map — Automatically generate sitemap.xml
54 | - 📦 Expansion Pack — Easily install common libraries, additional components, and configs.
55 |
56 | ## Tailwind CSS Version
57 |
58 | This starter is original from theodorusclarence/ts-nextjs-tailwind-starter, thank you theodorusclarence! If you prefer Tailwind css, check this: [Tailwind-CSS-Version](https://github.com/theodorusclarence/ts-nextjs-tailwind-starter)
59 |
60 | ## Getting Started
61 |
62 | ### 1. Clone this template using one of a few ways
63 |
64 | 1. Test locally: Using `create-next-app`
65 |
66 | ```bash
67 | npx create-next-app -e https://github.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter new-project-name
68 | ```
69 |
70 | 2. Test online: Deploy to Vercel by one click
71 |
72 | [](https://vercel.com/new/clone?s=https%3A%2F%2Fgithub.com%2FAlexStack%2Fnextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter&showOptionalTeamCreation=false)
73 |
74 | ### 2. Install dependencies
75 |
76 | It is encouraged to use **yarn** so the husky hooks can work properly.
77 |
78 | ```bash
79 | yarn install
80 | ```
81 |
82 | ### 3. Run the development server
83 |
84 | You can start the server using this command:
85 |
86 | ```bash
87 | yarn dev
88 | ```
89 |
90 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. You can start editing the page by modifying `src/pages/index.tsx`.
91 |
92 | ### 4. Change defaults
93 |
94 | There are some things you need to change including title, urls, favicons, etc.
95 |
96 | Find all comments with !STARTERCONF, then follow the guide.
97 |
98 | Don't forget to change the package name in package.json
99 |
100 | ### 5. Commit Message Convention
101 |
102 | This starter is using [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/), it is mandatory to use it to commit changes.
103 |
104 | ## Projects using this starter
105 |
106 |
111 |
112 | - [HiHB](https://hihb.com/)
113 |
114 | Are you using this starter? Please add your page (and repo) to the end of the list via a [Pull Request](https://github.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter/edit/main/README.md). 😃
115 |
116 | ## Folder structure
117 |
118 | 
119 |
120 | ## app/page.tsx code example
121 |
122 | 🚘🚘🚘 [**Click here to see an online demo of below code**](https://mui-nextjs-ts.vercel.app) 🚘🚘🚘
123 |
124 | 
125 |
126 | ## License
127 |
128 | - MIT
129 |
--------------------------------------------------------------------------------
/commitlint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['@commitlint/config-conventional'],
3 | rules: {
4 | // TODO Add Scope Enum Here
5 | // 'scope-enum': [2, 'always', ['yourscope', 'yourscope']],
6 | 'type-enum': [
7 | 2,
8 | 'always',
9 | [
10 | 'feat',
11 | 'fix',
12 | 'docs',
13 | 'chore',
14 | 'style',
15 | 'refactor',
16 | 'ci',
17 | 'test',
18 | 'perf',
19 | 'revert',
20 | 'vercel',
21 | ],
22 | ],
23 | },
24 | };
25 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line @typescript-eslint/no-var-requires
2 | const nextJest = require('next/jest');
3 |
4 | const createJestConfig = nextJest({
5 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment
6 | dir: './',
7 | });
8 |
9 | // Add any custom config to be passed to Jest
10 | const customJestConfig = {
11 | // Add more setup options before each test is run
12 | setupFilesAfterEnv: ['/jest.setup.js'],
13 |
14 | // if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work
15 | moduleDirectories: ['node_modules', '/'],
16 |
17 | testEnvironment: 'jest-environment-jsdom',
18 |
19 | /**
20 | * Absolute imports and Module Path Aliases
21 | */
22 | moduleNameMapper: {
23 | '^@/(.*)$': '/src/$1',
24 | '^~/(.*)$': '/public/$1',
25 | '^.+\\.(svg)$': '/src/__mocks__/svg.tsx',
26 | },
27 | };
28 |
29 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
30 | module.exports = createJestConfig(customJestConfig);
31 |
--------------------------------------------------------------------------------
/jest.setup.js:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom/extend-expect';
2 |
3 | // Allow router mocks.
4 | // eslint-disable-next-line no-undef
5 | jest.mock('next/router', () => require('next-router-mock'));
6 |
--------------------------------------------------------------------------------
/next-sitemap.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('next-sitemap').IConfig}
3 | * @see https://github.com/iamvishnusankar/next-sitemap#readme
4 | */
5 | module.exports = {
6 | // !STARTERCONF Change the siteUrl
7 | /** Without additional '/' on the end, e.g. https://google.com */
8 | siteUrl:
9 | 'https://github.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter',
10 | generateRobotsTxt: true,
11 | robotsTxtOptions: {
12 | policies: [{ userAgent: '*', allow: '/' }],
13 | },
14 | };
15 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | eslint: {
4 | dirs: ['src'],
5 | },
6 |
7 | reactStrictMode: true,
8 | swcMinify: true,
9 |
10 | // Uncoment to add domain whitelist
11 | // images: {
12 | // domains: [
13 | // 'res.cloudinary.com',
14 | // ],
15 | // },
16 |
17 | webpack(config) {
18 | // Grab the existing rule that handles SVG imports
19 | const fileLoaderRule = config.module.rules.find((rule) =>
20 | rule.test?.test?.('.svg')
21 | );
22 |
23 | config.module.rules.push(
24 | // Reapply the existing rule, but only for svg imports ending in ?url
25 | {
26 | ...fileLoaderRule,
27 | test: /\.svg$/i,
28 | resourceQuery: /url/, // *.svg?url
29 | },
30 | // Convert all other *.svg imports to React components
31 | {
32 | test: /\.svg$/i,
33 | issuer: { not: /\.(css|scss|sass)$/ },
34 | resourceQuery: { not: /url/ }, // exclude if *.svg?url
35 | loader: '@svgr/webpack',
36 | options: {
37 | dimensions: false,
38 | titleProp: true,
39 | },
40 | }
41 | );
42 |
43 | // Modify the file loader rule to ignore *.svg, since we have it handled now.
44 | fileLoaderRule.exclude = /\.svg$/i;
45 |
46 | return config;
47 | },
48 | };
49 |
50 | module.exports = nextConfig;
51 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react19-nextjs15-materia-mui6-typescript-hook-form-scaffold-boilerplate-starter",
3 | "version": "2.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "lint:fix": "eslint src --fix && yarn format",
11 | "lint:strict": "eslint --max-warnings=0 src",
12 | "typecheck": "tsc --noEmit --incremental false",
13 | "test:watch": "jest --watch",
14 | "test": "jest",
15 | "format": "prettier -w .",
16 | "format:check": "prettier -c .",
17 | "postbuild": "next-sitemap --config next-sitemap.config.js",
18 | "prepare": "husky install"
19 | },
20 | "dependencies": {
21 | "@emotion/react": "^11.11.1",
22 | "@emotion/styled": "^11.11.0",
23 | "@hookform/resolvers": "^3.3.1",
24 | "@mui/icons-material": "next",
25 | "@mui/material": "next",
26 | "dayjs": "^1.11.10",
27 | "next": "rc",
28 | "react": "rc",
29 | "react-dom": "rc",
30 | "react-hook-form": "^7.52.2",
31 | "react-icons": "^4.10.1",
32 | "zod": "^3.22.4"
33 | },
34 | "devDependencies": {
35 | "@commitlint/cli": "^16.3.0",
36 | "@commitlint/config-conventional": "^16.2.4",
37 | "@svgr/webpack": "^8.0.1",
38 | "@testing-library/jest-dom": "^5.16.5",
39 | "@testing-library/react": "^13.4.0",
40 | "@types/react": "^18.2.15",
41 | "@types/jest": "^29.5.12",
42 | "@typescript-eslint/eslint-plugin": "^5.62.0",
43 | "@typescript-eslint/parser": "^5.62.0",
44 | "autoprefixer": "^10.4.14",
45 | "eslint": "^8.45.0",
46 | "eslint-config-next": "rc",
47 | "eslint-config-prettier": "^8.8.0",
48 | "eslint-plugin-simple-import-sort": "^7.0.0",
49 | "eslint-plugin-unused-imports": "^2.0.0",
50 | "husky": "^7.0.4",
51 | "jest": "^27.5.1",
52 | "lint-staged": "^12.5.0",
53 | "next-router-mock": "^0.7.5",
54 | "next-sitemap": "^2.5.28",
55 | "postcss": "^8.4.26",
56 | "prettier": "^2.8.8",
57 | "typescript": "^4.9.5"
58 | },
59 | "lint-staged": {
60 | "**/*.{js,jsx,ts,tsx}": [
61 | "eslint --max-warnings=0",
62 | "prettier -w"
63 | ],
64 | "**/*.{json,css,scss,md,webmanifest}": [
65 | "prettier -w"
66 | ]
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter/42d19b3af720de2669e3ccf101dfbc6a6e7f5692/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter/42d19b3af720de2669e3ccf101dfbc6a6e7f5692/public/favicon/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/favicon/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter/42d19b3af720de2669e3ccf101dfbc6a6e7f5692/public/favicon/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/favicon/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter/42d19b3af720de2669e3ccf101dfbc6a6e7f5692/public/favicon/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/favicon/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter/42d19b3af720de2669e3ccf101dfbc6a6e7f5692/public/favicon/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter/42d19b3af720de2669e3ccf101dfbc6a6e7f5692/public/favicon/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter/42d19b3af720de2669e3ccf101dfbc6a6e7f5692/public/favicon/favicon.ico
--------------------------------------------------------------------------------
/public/favicon/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "short_name": "",
4 | "icons": [
5 | {
6 | "src": "/favicon/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/favicon/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/public/fonts/inter-var-latin.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter/42d19b3af720de2669e3ccf101dfbc6a6e7f5692/public/fonts/inter-var-latin.woff2
--------------------------------------------------------------------------------
/public/images/app-page-tsx.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter/42d19b3af720de2669e3ccf101dfbc6a6e7f5692/public/images/app-page-tsx.png
--------------------------------------------------------------------------------
/public/images/cover.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter/42d19b3af720de2669e3ccf101dfbc6a6e7f5692/public/images/cover.png
--------------------------------------------------------------------------------
/public/images/new-tab.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter/42d19b3af720de2669e3ccf101dfbc6a6e7f5692/public/images/new-tab.png
--------------------------------------------------------------------------------
/public/images/next-mui-folders.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter/42d19b3af720de2669e3ccf101dfbc6a6e7f5692/public/images/next-mui-folders.png
--------------------------------------------------------------------------------
/public/images/og.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter/42d19b3af720de2669e3ccf101dfbc6a6e7f5692/public/images/og.jpg
--------------------------------------------------------------------------------
/public/svg/Logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/svg/Vercel.svg:
--------------------------------------------------------------------------------
1 | Vercel
--------------------------------------------------------------------------------
/src/__mocks__/svg.tsx:
--------------------------------------------------------------------------------
1 | import React, { SVGProps } from 'react';
2 |
3 | const SvgrMock = React.forwardRef>(
4 | (props, ref) =>
5 | );
6 |
7 | export const ReactComponent = SvgrMock;
8 | export default SvgrMock;
9 |
--------------------------------------------------------------------------------
/src/app/api/test/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 |
3 | // Test local NextJs API /api/test method GET with parameters
4 | export const GET = async (req: Request) => {
5 | const { searchParams } = new URL(req.url);
6 | const reqData = Object.fromEntries(searchParams);
7 | return NextResponse.json({
8 | message: 'Test getApiResponse GET success!',
9 | method: 'GET',
10 | reqData,
11 | });
12 | };
13 |
14 | // Test local NextJs API /api/test method POST with variables
15 | export const POST = async (req: Request) => {
16 | const reqData = await req.json();
17 | return NextResponse.json({
18 | message: 'Test postApiResponse POST success!',
19 | method: 'POST',
20 | reqData,
21 | });
22 | };
23 |
--------------------------------------------------------------------------------
/src/app/error.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | 'use client'; // Error components must be Client Components
3 |
4 | import WarningIcon from '@mui/icons-material/Warning';
5 | import { Box, Button } from '@mui/material';
6 | import * as React from 'react';
7 |
8 | import { consoleLog } from '@/utils/shared/console-log';
9 |
10 | export default function Error({
11 | error,
12 | reset,
13 | }: {
14 | error: Error & { digest?: string };
15 | reset: () => void;
16 | }) {
17 | React.useEffect(() => {
18 | consoleLog('error.tsx', error);
19 | }, [error]);
20 |
21 | return (
22 |
23 |
24 |
25 |
26 | Oops, something went wrong!
27 | change this in app/error.tsx
28 | {error.message}
29 |
30 | Try again
31 |
32 | Back to home
33 |
34 |
38 |
39 |
40 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Container } from '@mui/material';
2 | import GlobalStyles from '@mui/material/GlobalStyles';
3 | import { Metadata } from 'next';
4 | import * as React from 'react';
5 |
6 | import { SITE_CONFIG } from '@/constants';
7 | import { GLOBAL_STYLES } from '@/styles';
8 |
9 | // !STARTERCONF Change these default meta
10 | // !STARTERCONF Look at @/constant/config to change them
11 | export const metadata: Metadata = {
12 | title: {
13 | default: SITE_CONFIG.title,
14 | template: `%s | ${SITE_CONFIG.title}`,
15 | },
16 | description: SITE_CONFIG.description,
17 | robots: { index: true, follow: true },
18 | metadataBase: new URL(SITE_CONFIG.url),
19 | icons: {
20 | icon: '/favicon/favicon.ico',
21 | shortcut: '/favicon/favicon-16x16.png',
22 | apple: '/favicon/apple-touch-icon.png',
23 | },
24 | manifest: `/favicon/site.webmanifest`,
25 | openGraph: {
26 | url: SITE_CONFIG.url,
27 | title: SITE_CONFIG.title,
28 | description: SITE_CONFIG.description,
29 | siteName: SITE_CONFIG.title,
30 | images: [`${SITE_CONFIG.url}/images/og.jpg`],
31 | type: 'website',
32 | locale: 'en_US',
33 | },
34 | twitter: {
35 | card: 'summary_large_image',
36 | title: SITE_CONFIG.title,
37 | description: SITE_CONFIG.description,
38 | images: [`${SITE_CONFIG.url}/images/og.jpg`],
39 | },
40 | authors: [
41 | {
42 | name: 'Alex',
43 | url: 'https://hihb.com',
44 | },
45 | ],
46 | };
47 |
48 | export default function RootLayout({
49 | children,
50 | }: {
51 | children: React.ReactNode;
52 | }) {
53 | return (
54 |
55 |
56 |
57 | {children}
58 |
59 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/src/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 | 'use client';
3 |
4 | import { Box } from '@mui/material';
5 | import { Metadata } from 'next';
6 | import { usePathname } from 'next/navigation';
7 | import * as React from 'react';
8 | import { RiAlarmWarningFill } from 'react-icons/ri';
9 |
10 | export const metadata: Metadata = {
11 | title: 'Not Found',
12 | };
13 |
14 | export default function NotFound() {
15 | const pathname = usePathname();
16 | return (
17 |
18 |
19 |
20 |
24 |
Page Not Found
25 | {/*
{window.location.href} NOT exists
*/}
26 |
{pathname} NOT exists
27 |
change this in app/not-found.tsx
28 |
Back to home
29 |
30 |
34 |
35 |
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Homepage from '@/components/Homepage';
2 |
3 | import { getApiResponse } from '@/utils/shared/get-api-response';
4 |
5 | import { NpmData, PageParams } from '@/types';
6 |
7 | const loadDataFromApi = async (slug?: string) => {
8 | if (slug === 'testError500') {
9 | throw new Error('This is mock a SSR 500 test error');
10 | }
11 |
12 | // Fetch & cache data from 2 remote APIs test
13 | const [reactNpmData, nextJsNpmData] = await Promise.all([
14 | getApiResponse({
15 | apiEndpoint: 'https://registry.npmjs.org/react/rc',
16 | revalidate: 60 * 60 * 24, // 24 hours cache
17 | timeout: 5000, // 5 seconds
18 | }),
19 | getApiResponse({
20 | apiEndpoint: 'https://registry.npmjs.org/next/rc',
21 | revalidate: 0, // no cache
22 | timeout: 5000, // 5 seconds
23 | }),
24 | ]);
25 |
26 | return {
27 | reactNpmData,
28 | nextJsNpmData,
29 | };
30 | };
31 |
32 | const AppHome = async ({ searchParams }: PageParams) => {
33 | const slug = searchParams?.slug;
34 | const { reactNpmData, nextJsNpmData } = await loadDataFromApi(slug);
35 |
36 | return (
37 |
41 | );
42 | };
43 |
44 | export default AppHome;
45 |
--------------------------------------------------------------------------------
/src/components/Homepage.tsx:
--------------------------------------------------------------------------------
1 | import AutoAwesome from '@mui/icons-material/AutoAwesome';
2 | import { Box, Typography } from '@mui/material';
3 | import Link from 'next/link';
4 |
5 | import BottomLinks from '@/components/homepage/BottomLinks';
6 | import DisplayRandomPicture from '@/components/homepage/DisplayRandomPicture';
7 | import PageFooter from '@/components/homepage/PageFooter';
8 | import ReactActionForm from '@/components/homepage/ReactActionForm';
9 | import ReactHookForm from '@/components/homepage/ReactHookForm';
10 | import ClientSideWrapper from '@/components/shared/ClientSideWrapper';
11 |
12 | import { FETCH_API_CTX_VALUE, SITE_CONFIG } from '@/constants';
13 |
14 | export default function Homepage({
15 | reactVersion = 'unknown',
16 | nextJsVersion = 'unknown',
17 | }) {
18 | return (
19 |
20 |
21 |
22 |
26 |
32 | {SITE_CONFIG.title}
33 |
34 |
39 | {SITE_CONFIG.description}
40 |
41 |
42 |
47 | Fetch & cache data from 2 remote APIs test:
48 | The React RC version is {reactVersion}, and the NextJs RC version is{' '}
49 | {nextJsVersion}
50 |
51 | On dev environment, you can see how long it takes on console, e.g.
52 | getApiResponse: 0.05s
53 |
54 |
55 |
56 |
57 |
61 | Test local NextJs API /api/test method GET with parameters
62 |
63 |
64 |
65 |
66 |
67 | Test local NextJs API /api/test POST method (client-side
68 | component)
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | );
82 | }
83 |
--------------------------------------------------------------------------------
/src/components/homepage/BottomLinks.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Box } from '@mui/material';
4 | import Link from 'next/link';
5 | import * as React from 'react';
6 |
7 | import { useSharedUtilContext } from '@/hooks/useSharedUtilContext';
8 |
9 | const BottomLinks = () => {
10 | const { openConfirmDialog } = useSharedUtilContext();
11 |
12 | return (
13 |
14 |
15 |
19 | See the Github repository page
20 |
21 |
22 |
23 | {
27 | e.preventDefault();
28 | openConfirmDialog({
29 | title: 'Copy this repository to your Vercel',
30 | content:
31 | 'Please make sure you have a Vercel account and login first',
32 | onConfirm: () => {
33 | window.open((e.target as HTMLAnchorElement).href, '_blank');
34 | },
35 | hideCancelButton: true,
36 | });
37 | }}
38 | >
39 | Click here to deploy a demo site to your Vercel in 1 minute
40 |
41 |
42 |
43 |
44 | {
47 | e.preventDefault();
48 | openConfirmDialog({
49 | title: 'Mock a page not found',
50 | content:
51 | 'This is an URL not exists, click OK you will see a custom 404 error page. You can also test the 404 page by typing a random URL in the browser address bar.',
52 | onConfirm: () => {
53 | window.open((e.target as HTMLAnchorElement).href, '_blank');
54 | },
55 | hideCancelButton: true,
56 | });
57 | }}
58 | >
59 | Test 404 page not found (mock file not exists)
60 |
61 |
62 |
63 | {
66 | e.preventDefault();
67 | openConfirmDialog({
68 | title: 'Mock a server side error',
69 | content:
70 | 'This is mock throw a server side error, click OK you will see a custom 500 error page. ',
71 | onConfirm: () => {
72 | window.open((e.target as HTMLAnchorElement).href, '_blank');
73 | },
74 | });
75 | }}
76 | >
77 | Test 500 error page (mock server side throw error)
78 |
79 |
80 |
81 | );
82 | };
83 |
84 | export default BottomLinks;
85 |
--------------------------------------------------------------------------------
/src/components/homepage/DisplayRandomPicture.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @next/next/no-img-element */
2 |
3 | 'use client';
4 | import styled from '@emotion/styled';
5 | import { Autorenew, Send } from '@mui/icons-material';
6 | import { css, keyframes } from '@mui/material';
7 | import Avatar from '@mui/material/Avatar';
8 | import Button from '@mui/material/Button';
9 | import { purple } from '@mui/material/colors';
10 | import Stack from '@mui/material/Stack';
11 | import React, { useEffect, useState, useTransition } from 'react';
12 |
13 | import { useClientContext } from '@/hooks/useClientContext';
14 | import { useSharedUtilContext } from '@/hooks/useSharedUtilContext';
15 |
16 | import SubmitButton from '@/components/shared/SubmitButton';
17 |
18 | import { FetchApiContext } from '@/constants';
19 | import { consoleLog } from '@/utils/shared/console-log';
20 | import { getApiResponse } from '@/utils/shared/get-api-response';
21 |
22 | const DisplayRandomPicture = () => {
23 | const [imageUrl, setImageUrl] = useState('');
24 | const [error, setError] = useState('');
25 | const { fetchCount, updateClientCtx } = useClientContext();
26 | const { setAlertBarProps } = useSharedUtilContext();
27 | const renderCountRef = React.useRef(0);
28 | const [isPending, startTransition] = useTransition();
29 |
30 | const fetchRandomPicture = async () => {
31 | startTransition(async () => {
32 | if (isPending) {
33 | setAlertBarProps({
34 | message: 'Please wait for the current fetch to complete',
35 | severity: 'warning',
36 | });
37 | return;
38 | }
39 | setError('');
40 |
41 | try {
42 | const response = await getApiResponse({
43 | apiEndpoint: 'https://picsum.photos/300/160',
44 | timeout: 5001,
45 | });
46 |
47 | if (!response?.url) {
48 | throw new Error('Error fetching the image, no response url');
49 | }
50 |
51 | setImageUrl(response.url);
52 | updateClientCtx({ fetchCount: fetchCount + 1 });
53 | setAlertBarProps({
54 | message: 'A random picture fetched successfully',
55 | severity: 'info',
56 | onClose: () => {
57 | consoleLog('Alert bar closed');
58 | },
59 | });
60 | } catch (error) {
61 | const errorMsg =
62 | error instanceof Error ? error.message : 'Error fetching the image';
63 |
64 | setError(errorMsg);
65 | setAlertBarProps({
66 | message: errorMsg,
67 | severity: 'error',
68 | });
69 | } finally {
70 | // setLoading(false);
71 | }
72 | });
73 | };
74 |
75 | useEffect(() => {
76 | if (renderCountRef.current === 0 && !isPending) {
77 | fetchRandomPicture();
78 | }
79 | renderCountRef.current += 1;
80 | });
81 |
82 | return (
83 |
90 | {imageUrl && (
91 |
97 | )}
98 | {error && {error}
}
99 |
100 | {isPending && Loading... } Component Render Count:{' '}
101 | {renderCountRef.current + 1}
102 |
103 |
104 |
108 | }
111 | onClick={fetchRandomPicture}
112 | disabled={isPending}
113 | color='secondary'
114 | >
115 | Get Another Picture
116 |
117 |
118 | {imageUrl && (
119 |
123 |
124 |
125 |
126 |
127 | )}
128 |
129 | );
130 | };
131 |
132 | const spin = keyframes`
133 | from {
134 | transform: rotate(0deg);
135 | }
136 | to {
137 | transform: rotate(360deg);
138 | }
139 | `;
140 | const StyledRefreshButton = styled.div<{ loading: number }>`
141 | position: absolute;
142 | right: 0;
143 | top: 0;
144 | margin: 0.5rem !important;
145 | pointer-events: ${({ loading }) => (loading ? 'none' : 'auto')};
146 | opacity: ${({ loading }) => (loading ? '0.6' : '1')};
147 | cursor: ${({ loading }) => (loading ? 'not-allowed' : 'pointer')};
148 | svg {
149 | width: 20px;
150 | height: 20px;
151 | animation: ${({ loading }) =>
152 | loading
153 | ? css`
154 | ${spin} 2s linear infinite
155 | `
156 | : 'none'};
157 | }
158 | :hover {
159 | svg {
160 | path {
161 | fill: ${purple[500]};
162 | }
163 | }
164 | .MuiAvatar-circular {
165 | background-color: ${purple[50]};
166 | }
167 | }
168 | `;
169 |
170 | export default DisplayRandomPicture;
171 |
--------------------------------------------------------------------------------
/src/components/homepage/PageFooter.tsx:
--------------------------------------------------------------------------------
1 | import { Box } from '@mui/material';
2 | import * as React from 'react';
3 |
4 | import ServerDateTime from '@/components/shared/ServerDateTime';
5 |
6 | const PageFooter = () => {
7 | return (
8 |
9 |
10 | PageFooter.tsx © Boilerplate live example:
11 |
12 | HiHB
13 |
14 |
15 |
16 |
17 |
18 |
19 | );
20 | };
21 |
22 | export default PageFooter;
23 |
--------------------------------------------------------------------------------
/src/components/homepage/ReactActionForm.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import styled from '@emotion/styled';
4 | import { Save } from '@mui/icons-material';
5 | import {
6 | Avatar,
7 | Box,
8 | Button,
9 | FormHelperText,
10 | Stack,
11 | TextField,
12 | } from '@mui/material';
13 | import { purple } from '@mui/material/colors';
14 | import React, { useActionState, useOptimistic } from 'react';
15 | import { z, ZodError } from 'zod';
16 |
17 | import { useClientContext } from '@/hooks/useClientContext';
18 | import { useSharedUtilContext } from '@/hooks/useSharedUtilContext';
19 |
20 | import SubmitButton from '@/components/shared/SubmitButton';
21 |
22 | import { FetchApiContext } from '@/constants';
23 | import { consoleLog } from '@/utils/shared/console-log';
24 | import { getApiResponse } from '@/utils/shared/get-api-response';
25 |
26 | const zodSchema = z.object({
27 | name: z
28 | .string()
29 | .min(3, { message: 'Name must contain at least 3 characters' })
30 | .nonempty({ message: 'Name is required' }),
31 | email: z
32 | .string()
33 | .min(10, { message: 'Email must contain at least 10 characters' })
34 | .email({ message: 'Invalid email address' }),
35 | });
36 |
37 | const StyledForm = styled.form`
38 | .MuiFormHelperText-root {
39 | text-align: center;
40 | color: darkred;
41 | margin-bottom: 1rem;
42 | }
43 | `;
44 |
45 | type FormValues = z.infer;
46 |
47 | const ReactActionForm: React.FC = () => {
48 | const apiEndpoint = '/api/test';
49 | const [apiResult, setApiResult] = React.useState();
50 | const [optimisticApiResult, setOptimisticApiResult] = useOptimistic<
51 | FormValues | undefined
52 | >(undefined);
53 |
54 | const { setAlertBarProps } = useSharedUtilContext();
55 |
56 | const { fetchCount, updateClientCtx } = useClientContext();
57 | const [formErrors, setFormErrors] = React.useState>(
58 | {}
59 | );
60 |
61 | const resolveZodError = (error: ZodError): Record => {
62 | const errors: Record = {};
63 | error.errors.forEach((err) => {
64 | if (err.path && err.path.length > 0) {
65 | const field = err.path[0];
66 | errors[field as string] = err.message;
67 | }
68 | });
69 | return errors;
70 | };
71 |
72 | const submitFormFn = async (
73 | previousState: FormValues | undefined,
74 | formData: FormData
75 | ) => {
76 | const formFieldValues = Object.fromEntries(formData) as FormValues;
77 |
78 | try {
79 | const zodResult = zodSchema.safeParse(formFieldValues);
80 | if (!zodResult.success) {
81 | if (zodResult.error instanceof ZodError) {
82 | const newErrors = resolveZodError(zodResult.error);
83 | setFormErrors(newErrors);
84 | }
85 | setAlertBarProps({
86 | message: 'Please fix the form errors',
87 | severity: 'warning',
88 | autoHideSeconds: 4,
89 | });
90 | throw new Error('Invalid zodSchema form data');
91 | }
92 | setOptimisticApiResult(formFieldValues);
93 | setFormErrors({});
94 | const result = await getApiResponse<{
95 | reqData: FormValues;
96 | }>({
97 | apiEndpoint,
98 | method: 'POST',
99 | requestData: JSON.stringify(Object.fromEntries(formData)),
100 | });
101 | setApiResult(result?.reqData);
102 | await new Promise((resolve) => setTimeout(resolve, 2000));
103 |
104 | setAlertBarProps({
105 | message: 'Form submitted successfully',
106 | severity: 'success',
107 | });
108 | updateClientCtx({ fetchCount: fetchCount + 1 });
109 | } catch (error) {
110 | consoleLog('submitFormFn ERROR', error, formData);
111 | setAlertBarProps({
112 | message: 'Form submission failed',
113 | severity: 'error',
114 | });
115 | }
116 |
117 | return formFieldValues;
118 | };
119 |
120 | const [actionState, submitAction, isSubmitting] = useActionState(
121 | submitFormFn,
122 | {
123 | name: 'John Doe',
124 | email: 'john@react19.org',
125 | }
126 | );
127 |
128 | return (
129 |
130 |
131 |
137 | {!isSubmitting && formErrors.name && (
138 |
139 | {formErrors.name}
140 |
141 | )}
142 |
143 |
144 |
145 |
151 | {!isSubmitting && formErrors.email && (
152 |
153 | {formErrors.email}
154 |
155 | )}
156 |
157 | {optimisticApiResult && (
158 |
159 | React19 useOptimistic() API result: {optimisticApiResult.name} &{' '}
160 | {optimisticApiResult.email}
161 |
162 | )}
163 | {apiResult && !isSubmitting && (
164 |
165 | React19 action-form API result from {apiEndpoint}: {apiResult.name} &{' '}
166 | {apiResult.email}
167 |
168 | )}
169 |
170 |
174 | }>
175 | Test react19 form action
176 |
177 |
178 |
179 |
180 |
187 | Total fetch count from ReactActionForm.tsx:
188 |
197 | {fetchCount}
198 |
199 |
200 |
201 |
202 | );
203 | };
204 |
205 | export default ReactActionForm;
206 |
--------------------------------------------------------------------------------
/src/components/homepage/ReactHookForm.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import styled from '@emotion/styled';
4 | import { zodResolver } from '@hookform/resolvers/zod';
5 | import { NextPlan, Save } from '@mui/icons-material';
6 | import {
7 | Avatar,
8 | Box,
9 | Button,
10 | FormHelperText,
11 | Stack,
12 | TextField,
13 | } from '@mui/material';
14 | import { purple } from '@mui/material/colors';
15 | import React, { useEffect } from 'react';
16 | import { Controller, SubmitHandler, useForm } from 'react-hook-form';
17 | import { z } from 'zod';
18 |
19 | import { useClientContext } from '@/hooks/useClientContext';
20 | import { useSharedUtilContext } from '@/hooks/useSharedUtilContext';
21 |
22 | import SubmitButton from '@/components/shared/SubmitButton';
23 |
24 | import { FetchApiContext } from '@/constants';
25 | import { consoleLog } from '@/utils/shared/console-log';
26 | import { getApiResponse } from '@/utils/shared/get-api-response';
27 |
28 | const zodSchema = z.object({
29 | name: z
30 | .string()
31 | .min(3, { message: 'Name must contain at least 3 characters' })
32 | .nonempty({ message: 'Name is required' }),
33 | email: z
34 | .string()
35 | .min(10, { message: 'Email must contain at least 10 characters' })
36 | .email({ message: 'Invalid email address' }),
37 | });
38 |
39 | const StyledForm = styled.form`
40 | .MuiFormHelperText-root {
41 | text-align: center;
42 | color: darkred;
43 | margin-bottom: 1rem;
44 | }
45 | `;
46 |
47 | type FormValues = z.infer;
48 |
49 | const ReactHookForm: React.FC = () => {
50 | const apiEndpoint = '/api/test';
51 | const [apiResult, setApiResult] = React.useState();
52 | const [isSubmitting, setIsSubmitting] = React.useState(false);
53 |
54 | const { setAlertBarProps, openConfirmDialog } = useSharedUtilContext();
55 |
56 | const {
57 | handleSubmit,
58 | control,
59 | formState: { errors, isValid },
60 | setValue,
61 | } = useForm({
62 | resolver: zodResolver(zodSchema),
63 | });
64 |
65 | const { fetchCount, updateClientCtx } = useClientContext();
66 |
67 | const onSubmit: SubmitHandler = async (data) => {
68 | try {
69 | setIsSubmitting(true);
70 | const result = await getApiResponse<{
71 | reqData: FormValues;
72 | }>({
73 | apiEndpoint,
74 | method: 'POST',
75 | requestData: JSON.stringify(data),
76 | });
77 | setApiResult(result?.reqData);
78 | await new Promise((resolve) => setTimeout(resolve, 1000));
79 | setIsSubmitting(false);
80 |
81 | setAlertBarProps({
82 | message: 'Form submitted successfully',
83 | severity: 'success',
84 | });
85 | updateClientCtx({ fetchCount: fetchCount + 1 });
86 | } catch (error) {
87 | consoleLog('handleSubmit ERROR', error);
88 | setIsSubmitting(false);
89 | setAlertBarProps({
90 | message: 'Form submission failed',
91 | severity: 'error',
92 | });
93 | }
94 | };
95 |
96 | useEffect(() => {
97 | if (!isValid && Object.keys(errors).length > 0) {
98 | setAlertBarProps({
99 | message: 'Please fix the form errors',
100 | severity: 'warning',
101 | autoHideSeconds: 4,
102 | });
103 | }
104 | }, [isValid, errors, setAlertBarProps]);
105 |
106 | return (
107 |
108 |
109 | (
114 |
115 | )}
116 | />
117 | {errors.name && (
118 |
119 | {errors.name.message}
120 |
121 | )}
122 |
123 |
124 |
125 | (
130 |
131 | )}
132 | />
133 | {errors.email && (
134 |
135 | {errors.email.message}
136 |
137 | )}
138 |
139 | {apiResult && !isSubmitting && (
140 |
141 | API result from {apiEndpoint}: {apiResult.name} & {apiResult.email}
142 |
143 | )}
144 |
145 |
149 | }>
150 | Test react hook form with zod
151 |
152 |
153 |
154 |
155 |
162 | Total fetch count from React Context:
163 |
172 | {fetchCount}
173 |
174 |
175 | {
178 | const randomNumber = Math.floor(Math.random() * 90) + 10;
179 | openConfirmDialog({
180 | title: 'Change form name',
181 | content: `Are you sure to change above form name to Alex ${randomNumber} and submit?`,
182 | onConfirm: () => {
183 | setValue('name', `Alex ${randomNumber}`);
184 | setValue('email', 'alex@test.com');
185 | handleSubmit(onSubmit)();
186 | },
187 | });
188 | }}
189 | endIcon={ }
190 | >
191 | Test MUI confirmation dialog
192 |
193 |
194 |
195 | );
196 | };
197 |
198 | export default ReactHookForm;
199 |
--------------------------------------------------------------------------------
/src/components/shared/ClientSideWrapper.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { ClientProvider } from '@/hooks/useClientContext';
4 | import { SharedUtilProvider } from '@/hooks/useSharedUtilContext';
5 |
6 | const ClientSideWrapper = ({
7 | defaultContextValue,
8 | children,
9 | }: {
10 | defaultContextValue: unknown;
11 | children: React.ReactNode;
12 | }) => {
13 | return (
14 |
15 | {children}
16 |
17 | );
18 | };
19 |
20 | export default ClientSideWrapper;
21 |
--------------------------------------------------------------------------------
/src/components/shared/ServerDateTime.tsx:
--------------------------------------------------------------------------------
1 | import { Box } from '@mui/material';
2 | import dayjs from 'dayjs';
3 | import timezone from 'dayjs/plugin/timezone';
4 | import utc from 'dayjs/plugin/utc';
5 |
6 | dayjs.extend(utc);
7 | dayjs.extend(timezone);
8 |
9 | const ServerDateTime = ({
10 | cityTimezone,
11 | timeFormat = 'dddd, MMMM D, YYYY h:mm:ss A',
12 | color,
13 | date,
14 | }: {
15 | cityTimezone: string;
16 | timeFormat?: string;
17 | color?: string;
18 | date?: string;
19 | }) => {
20 | return (
21 |
22 | {dayjs(date).tz(cityTimezone).format(timeFormat)}
23 |
24 | );
25 | };
26 |
27 | export default ServerDateTime;
28 |
--------------------------------------------------------------------------------
/src/components/shared/SubmitButton.tsx:
--------------------------------------------------------------------------------
1 | import { ButtonProps, CircularProgress } from '@mui/material';
2 | import * as React from 'react';
3 |
4 | export interface SubmitButtonProps {
5 | children: React.ReactElement;
6 | isSubmitting?: boolean;
7 | submittingColor?: string;
8 | submittingSize?: string | number;
9 | submittingText?: string;
10 | }
11 |
12 | const SubmitButton = ({
13 | children,
14 | isSubmitting,
15 | submittingColor,
16 | submittingSize = '1rem',
17 | submittingText = 'Submitting...',
18 | }: SubmitButtonProps) => {
19 | const submittingIconColor = submittingColor || children.props.color;
20 | return (
21 | <>
22 | {React.cloneElement(children, {
23 | startIcon: !isSubmitting ? (
24 | children.props.startIcon
25 | ) : (
26 |
30 | ),
31 | disabled: children.props.disabled ?? isSubmitting,
32 | children:
33 | isSubmitting && submittingText
34 | ? submittingText
35 | : children.props.children,
36 | })}
37 | >
38 | );
39 | };
40 |
41 | export default SubmitButton;
42 |
--------------------------------------------------------------------------------
/src/constants/config.ts:
--------------------------------------------------------------------------------
1 | export const SITE_CONFIG = {
2 | title: 'ReactJs 19.x + NextJs 16.x + MUI 6.x + TypeScript Starter',
3 | description:
4 | 'The scaffold for ReactJs 19.x with NextJs 15.x (App Router), React Hook Form, Material UI(MUI 6.x),Typescript and ESLint, and TypeScript with Absolute Import, Seo, Link component, pre-configured with Husky',
5 | /** Without additional '/' on the end, e.g. https://hihb.com */
6 | url: 'https://hihb.com',
7 | };
8 |
9 | export const HIDE_DEBUG_ARY = [
10 | // 'getApiResponse',
11 | 'getMongoDbApiData',
12 | ];
13 |
--------------------------------------------------------------------------------
/src/constants/context.ts:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 |
3 | export interface FetchApiContext {
4 | topError: ReactNode;
5 | fetchCount: number;
6 | }
7 |
8 | export const FETCH_API_CTX_VALUE: FetchApiContext = {
9 | topError: null,
10 | fetchCount: 0,
11 | };
12 |
13 | // You can add more context interface & values here and use them in different places
14 | export interface AnotherContext {
15 | someValue: string;
16 | secondValue?: number;
17 | }
18 |
19 | export const ANOTHER_CTX_VALUE: AnotherContext = {
20 | someValue: 'default value',
21 | secondValue: 0,
22 | };
23 |
--------------------------------------------------------------------------------
/src/constants/env.ts:
--------------------------------------------------------------------------------
1 | export const IS_PROD = process.env.NODE_ENV === 'production';
2 | export const IS_DEV = process.env.NODE_ENV === 'development';
3 |
4 | export const SHOW_CONSOLE_LOG = IS_DEV
5 | ? true
6 | : process.env.NEXT_PUBLIC_SHOW_LOGGER === 'true' ?? false;
7 |
--------------------------------------------------------------------------------
/src/constants/index.ts:
--------------------------------------------------------------------------------
1 | export * from './config';
2 | export * from './context';
3 | export * from './env';
4 |
--------------------------------------------------------------------------------
/src/hooks/useAlertBar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import CloseIcon from '@mui/icons-material/Close';
4 | import {
5 | Alert,
6 | AlertColor,
7 | IconButton,
8 | Slide,
9 | Snackbar,
10 | SnackbarOrigin,
11 | } from '@mui/material';
12 | import React, { useState } from 'react';
13 |
14 | export interface AlertBarProps {
15 | message: React.ReactNode;
16 | onClose?: () => void;
17 | severity?: AlertColor;
18 | vertical?: SnackbarOrigin['vertical'];
19 | horizontal?: SnackbarOrigin['horizontal'];
20 | autoHideSeconds?: number;
21 | transitionSeconds?: number;
22 | }
23 |
24 | // my personal alert bar, feel free to use it, examples are in ReactHookForm.tsx
25 | export const useAlertBar = () => {
26 | const [alertBarProps, setAlertBarProps] = useState({
27 | message: '',
28 | severity: 'info',
29 | });
30 | const onAlertClose = () => {
31 | setAlertBarProps({ message: '' });
32 | alertBarProps.onClose?.();
33 | };
34 |
35 | const renderAlertBar = () => {
36 | const {
37 | message,
38 | severity,
39 | vertical = 'bottom',
40 | horizontal = 'center',
41 | autoHideSeconds = 5,
42 | transitionSeconds = 0.5,
43 | } = alertBarProps;
44 |
45 | return message ? (
46 | }
52 | transitionDuration={transitionSeconds * 1000}
53 | action={
54 |
60 |
61 |
62 | }
63 | >
64 |
70 | {message}
71 |
72 |
73 | ) : null;
74 | };
75 |
76 | return {
77 | setAlertBarProps,
78 | renderAlertBar,
79 | };
80 | };
81 |
--------------------------------------------------------------------------------
/src/hooks/useClientContext.test.tsx:
--------------------------------------------------------------------------------
1 | import { renderHook } from '@testing-library/react';
2 | import React, { act } from 'react';
3 |
4 | import {
5 | ClientProvider,
6 | OUTSIDE_CLIENT_PROVIDER_ERROR,
7 | useClientContext,
8 | } from './useClientContext';
9 |
10 | describe('useClientContext', () => {
11 | it('should not be used outside ClientProvider', () => {
12 | try {
13 | renderHook(() => useClientContext());
14 | } catch (error) {
15 | expect(error).toEqual(new Error(OUTSIDE_CLIENT_PROVIDER_ERROR));
16 | }
17 | });
18 |
19 | it('should provide the correct initial context values', () => {
20 | const defaultCtxValue = {
21 | status: 'Pending',
22 | topError: '',
23 | fetchCount: 0,
24 | };
25 | const ctxValue = {
26 | topError: 'SWW Error',
27 | status: 'Live',
28 | fetchCount: 85,
29 | };
30 | const wrapper = ({ children }: { children: React.ReactNode }) => (
31 |
32 | {children}
33 |
34 | );
35 |
36 | const { result } = renderHook(
37 | () => useClientContext(),
38 | {
39 | wrapper,
40 | }
41 | );
42 |
43 | expect(result.current.topError).toBe(ctxValue.topError);
44 | expect(result.current.fetchCount).toBe(ctxValue.fetchCount);
45 | });
46 |
47 | it('should update the context values', () => {
48 | const defaultCtxValue = {
49 | picUrl: '',
50 | loading: false,
51 | total: 0,
52 | };
53 | const ctxValue = {
54 | picUrl: 'https://picsum.photos/300/160',
55 | loading: true,
56 | total: 3,
57 | };
58 | const wrapper = ({ children }: { children: React.ReactNode }) => (
59 |
60 | {children}
61 |
62 | );
63 |
64 | const { result } = renderHook(
65 | () => useClientContext(),
66 | {
67 | wrapper,
68 | }
69 | );
70 |
71 | const newCtxValue = {
72 | picUrl: 'https://picsum.photos/200/150',
73 | loading: false,
74 | };
75 |
76 | act(() => {
77 | result.current.updateClientCtx(newCtxValue);
78 | });
79 |
80 | expect(result.current.picUrl).toBe(newCtxValue.picUrl);
81 | expect(result.current.total).toBe(ctxValue.total); // not updated
82 | expect(result.current.loading).toBe(newCtxValue.loading);
83 | });
84 | });
85 |
--------------------------------------------------------------------------------
/src/hooks/useClientContext.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, {
4 | createContext,
5 | ReactNode,
6 | useCallback,
7 | useContext,
8 | useState,
9 | } from 'react';
10 |
11 | /**
12 | * This is a generic custom hook for updating the client context
13 | * It can be used in multiple places from any client-side component
14 | * Please change the per-defined type & default value in constants/context.ts
15 | */
16 |
17 | export const OUTSIDE_CLIENT_PROVIDER_ERROR =
18 | 'Cannot be used outside ClientProvider!';
19 |
20 | export interface UpdateClientCtxType {
21 | updateClientCtx: (props: Partial) => void;
22 | }
23 |
24 | export const ClientContext = createContext(undefined);
25 |
26 | export const useClientContext = (): T & UpdateClientCtxType => {
27 | const context = useContext(ClientContext);
28 | if (context === undefined) {
29 | throw new Error(OUTSIDE_CLIENT_PROVIDER_ERROR);
30 | }
31 |
32 | return context as T & UpdateClientCtxType;
33 | };
34 |
35 | /**
36 | * You should pass the default value to the ClientProvider first
37 | * e.g.
38 | * Client-side component usage example:
39 | * const clientContext = useClientContext();
40 | * clientContext.updateClientCtx({ topError: 'Error message' });
41 | * clientContext.updateClientCtx({ fetchCount: 10 });
42 | * The total fetch count is: clientContext.fetchCount
43 | */
44 | export const ClientProvider = ({
45 | children,
46 | value,
47 | defaultValue,
48 | }: {
49 | children: ReactNode;
50 | value?: Partial;
51 | defaultValue: T;
52 | }) => {
53 | const [contextValue, setContextValue] = useState({
54 | ...defaultValue,
55 | ...value,
56 | updateClientCtx: (_: Partial): void => {
57 | throw new Error(OUTSIDE_CLIENT_PROVIDER_ERROR);
58 | },
59 | });
60 |
61 | const updateContext = useCallback(
62 | (newCtxValue: Partial) => {
63 | setContextValue((prevContextValue) => ({
64 | ...prevContextValue,
65 | ...newCtxValue,
66 | }));
67 | },
68 | [setContextValue]
69 | );
70 |
71 | return (
72 |
78 | {children}
79 |
80 | );
81 | };
82 |
--------------------------------------------------------------------------------
/src/hooks/useConfirmDialog.tsx:
--------------------------------------------------------------------------------
1 | import styled from '@emotion/styled';
2 | import { Close } from '@mui/icons-material';
3 | import { IconButton } from '@mui/material';
4 | import Button, { ButtonProps } from '@mui/material/Button';
5 | import Dialog, { DialogProps } from '@mui/material/Dialog';
6 | import DialogActions from '@mui/material/DialogActions';
7 | import DialogContent from '@mui/material/DialogContent';
8 | import DialogTitle from '@mui/material/DialogTitle';
9 | import React, { cloneElement, ReactNode, useState } from 'react';
10 |
11 | export interface ConfirmationDialogProps {
12 | title: string;
13 | content: ReactNode;
14 | onConfirm?: () => void;
15 | onCancel?: () => void;
16 | cancelText?: ReactNode;
17 | confirmText?: ReactNode;
18 | cancelButton?: React.ReactElement;
19 | confirmButton?: React.ReactElement;
20 | dialogPaperProps?: DialogProps['PaperProps'];
21 | autoClose?: boolean; // auto close dialog after confirm
22 | hideCancelButton?: boolean;
23 | hideCloseButton?: boolean;
24 | }
25 |
26 | const StyledContentDiv = styled.div`
27 | min-width: 19rem; // 19x16 = 304px
28 | .MuiDialogTitle-root {
29 | padding: 1rem;
30 | }
31 | .MuiDialogContent-root {
32 | padding: 0 1rem 1rem 1rem;
33 | }
34 | .close-button {
35 | position: absolute;
36 | top: 0.5rem;
37 | right: 0.5rem;
38 | padding: 0.2rem;
39 | color: gray;
40 | :hover {
41 | color: black;
42 | background: rgba(0, 0, 0, 0.1);
43 | }
44 | }
45 | `;
46 |
47 | // my personal confirmation dialog, feel free to use it, examples are in ReactHookForm.tsx
48 | const useConfirmationDialog = () => {
49 | const defaultDialogProps: ConfirmationDialogProps = {
50 | title: '',
51 | content: '',
52 | autoClose: true,
53 | hideCancelButton: false,
54 | hideCloseButton: false,
55 | };
56 | const [open, setOpen] = useState(false);
57 | const [dialogProps, setDialogProps] =
58 | useState(defaultDialogProps);
59 |
60 | const handleOpen = (config: ConfirmationDialogProps) => {
61 | setDialogProps({
62 | ...defaultDialogProps,
63 | ...config,
64 | confirmButton: config.confirmButton || (
65 |
66 | {config.confirmText || 'OK'}
67 |
68 | ),
69 | cancelButton: config.cancelButton || (
70 | {config.cancelText || 'Cancel'}
71 | ),
72 | });
73 | setOpen(true);
74 | };
75 |
76 | const handleClose = () => {
77 | setOpen(false);
78 | setDialogProps({
79 | ...defaultDialogProps,
80 | title: '',
81 | content: '',
82 | });
83 | };
84 |
85 | const handleConfirm = () => {
86 | dialogProps.onConfirm?.();
87 | dialogProps.autoClose && handleClose();
88 | };
89 |
90 | const handleCancel = () => {
91 | dialogProps.onCancel?.();
92 | dialogProps.autoClose && handleClose();
93 | };
94 |
95 | const renderConfirmationDialog = () => {
96 | return (
97 |
102 | {dialogProps.title && (
103 |
104 | {dialogProps.title}
105 | {!dialogProps.hideCloseButton && (
106 |
112 |
113 |
114 | )}
115 | {dialogProps.content}
116 |
117 | {dialogProps.cancelButton &&
118 | !dialogProps.hideCancelButton &&
119 | cloneElement(dialogProps.cancelButton, {
120 | onClick: handleCancel,
121 | })}
122 | {dialogProps.confirmButton &&
123 | cloneElement(dialogProps.confirmButton, {
124 | onClick: handleConfirm,
125 | })}
126 |
127 |
128 | )}
129 |
130 | );
131 | };
132 |
133 | return {
134 | renderConfirmationDialog,
135 | openConfirmDialog: handleOpen,
136 | closeConfirmDialog: handleClose,
137 | };
138 | };
139 |
140 | export default useConfirmationDialog;
141 |
--------------------------------------------------------------------------------
/src/hooks/useSharedUtilContext.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import React, { createContext, ReactNode, useContext } from 'react';
4 |
5 | import { AlertBarProps, useAlertBar } from '@/hooks/useAlertBar';
6 | import useConfirmationDialog, {
7 | ConfirmationDialogProps,
8 | } from '@/hooks/useConfirmDialog';
9 |
10 | export const OUTSIDE_SHARED_UTIL_PROVIDER_ERROR =
11 | 'Cannot be used outside SharedUtilProvider!';
12 |
13 | export interface SharedUtilContextType {
14 | setAlertBarProps: (props: AlertBarProps) => void;
15 | openConfirmDialog: (props: ConfirmationDialogProps) => void;
16 | }
17 |
18 | export const SharedUtilContext = createContext<
19 | SharedUtilContextType | undefined
20 | >(undefined);
21 |
22 | export const useSharedUtilContext = (): SharedUtilContextType => {
23 | const context = useContext(SharedUtilContext);
24 | if (context === undefined) {
25 | throw new Error(OUTSIDE_SHARED_UTIL_PROVIDER_ERROR);
26 | }
27 |
28 | return context as SharedUtilContextType;
29 | };
30 |
31 | /**
32 | * Provides a shared utility context for components.
33 | *
34 | * Add the frequently used utility here to avoid rendering them in every component.
35 | * They only need to be rendered once in the root component and can be used anywhere.
36 | */
37 | export const SharedUtilProvider = ({ children }: { children: ReactNode }) => {
38 | const { renderAlertBar, setAlertBarProps } = useAlertBar();
39 |
40 | const { renderConfirmationDialog, openConfirmDialog } =
41 | useConfirmationDialog();
42 |
43 | return (
44 |
50 | {children}
51 | {renderAlertBar()}
52 | {renderConfirmationDialog()}
53 |
54 | );
55 | };
56 |
--------------------------------------------------------------------------------
/src/styles/index.ts:
--------------------------------------------------------------------------------
1 | import { blue, grey } from '@mui/material/colors';
2 |
3 | export const GLOBAL_STYLES = {
4 | body: { margin: 4 },
5 | '.page-title': { color: 'darkblue' },
6 | '.page-subtitle': { color: grey[600] },
7 | a: {
8 | textDecoration: 'underline',
9 | textDecorationColor: blue[800],
10 | color: blue['700'],
11 | fontSize: '1rem',
12 | fontWeight: 400,
13 | lineHeight: '1.8',
14 | letterSpacing: '0.00938em',
15 | },
16 | };
17 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | export interface PageParams {
2 | params?: { id?: string };
3 | searchParams?: { [key: string]: string | undefined };
4 | }
5 |
6 | export interface NpmData {
7 | version: string;
8 | }
9 |
--------------------------------------------------------------------------------
/src/utils/__tests__/og.test.ts:
--------------------------------------------------------------------------------
1 | import { openGraph } from '@/utils/shared/og';
2 |
3 | describe('Open Graph function should work correctly', () => {
4 | it('should not return templateTitle when not specified', () => {
5 | const result = openGraph({
6 | description: 'Test description',
7 | siteName: 'Test site name',
8 | });
9 | expect(result).not.toContain('&templateTitle=');
10 | });
11 |
12 | it('should return templateTitle when specified', () => {
13 | const result = openGraph({
14 | templateTitle: 'Test Template Title',
15 | description: 'Test description',
16 | siteName: 'Test site name',
17 | });
18 | expect(result).toContain('&templateTitle=Test%20Template%20Title');
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/src/utils/shared/console-log.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 |
3 | import { HIDE_DEBUG_ARY, SHOW_CONSOLE_LOG } from '@/constants';
4 |
5 | export const consoleLog = (
6 | var1: unknown,
7 | var2: unknown = 'DEF_VAR_2',
8 | var3: unknown = 'DEF_VAR_3',
9 | var4: unknown = 'DEF_VAR_4',
10 | var5: unknown = 'DEF_VAR_5'
11 | ) => {
12 | if (SHOW_CONSOLE_LOG) {
13 | const newVar1 = typeof var1 === 'string' ? `🤔🙏🚀 ${var1}` : var1;
14 | if (typeof var1 === 'string') {
15 | if (HIDE_DEBUG_ARY.some((item) => var1.includes(item))) {
16 | // console.log('H💖 🔥 💪 👌 👍 💔 😅');
17 | console.log('H');
18 | return;
19 | }
20 | }
21 |
22 | if (var5 !== 'DEF_VAR_5') {
23 | console.log(newVar1, var2, var3, var4, var5);
24 | } else if (var4 !== 'DEF_VAR_4') {
25 | console.log(newVar1, var2, var3, var4);
26 | } else if (var3 !== 'DEF_VAR_3') {
27 | console.log(newVar1, var2, var3);
28 | } else if (var2 !== 'DEF_VAR_2') {
29 | console.log(newVar1, var2);
30 | } else {
31 | console.log(newVar1);
32 | }
33 | }
34 | };
35 |
--------------------------------------------------------------------------------
/src/utils/shared/get-api-response.ts:
--------------------------------------------------------------------------------
1 | import { IS_PROD } from '@/constants';
2 | import { consoleLog } from '@/utils/shared/console-log';
3 |
4 | /**
5 | * Makes an API request and returns the response data.
6 | *
7 | * @param apiEndpoint - The API endpoint URL.
8 | * @param requestData - The request data to be sent in the request body.
9 | * @param method - The HTTP method for the request (default: 'GET').
10 | * @param revalidate - The time in seconds to cache the data (default: 3600 seconds in production, 120 seconds otherwise).
11 | * @param headers - The headers to be included in the request.
12 | * @param timeout - The timeout in milliseconds for the request (default: 100000 = 100 seconds).
13 | * @returns The response data from the API.
14 | * @throws An error if the API request fails or times out.
15 | */
16 | export const getApiResponse = async ({
17 | apiEndpoint,
18 | requestData,
19 | method = 'GET',
20 | revalidate = IS_PROD ? 3600 : 120, // cache data in seconds
21 | headers,
22 | timeout = 100000, // 100 seconds
23 | }: {
24 | apiEndpoint: string;
25 | requestData?: BodyInit;
26 | method?: 'POST' | 'GET' | 'PUT' | 'DELETE';
27 | revalidate?: number;
28 | headers?: HeadersInit;
29 | timeout?: number;
30 | }) => {
31 | try {
32 | const startTime = Date.now();
33 | const controller = new AbortController();
34 | const signal = controller.signal;
35 |
36 | const timeoutId = setTimeout(() => controller.abort(), timeout);
37 |
38 | const response = await fetch(apiEndpoint, {
39 | method,
40 | body: requestData,
41 | headers,
42 | next: {
43 | revalidate,
44 | },
45 | signal,
46 | });
47 | if (!response.ok) {
48 | consoleLog('🚀 Debug getApiResponse requestData:', requestData);
49 |
50 | throw new Error(
51 | `😢 getApiResponse failed: ${response.status}/${response.statusText} - ${apiEndpoint}`
52 | );
53 | }
54 | const duration = Date.now() - startTime;
55 |
56 | consoleLog(
57 | `getApiResponse: ${(duration / 1000).toFixed(2)}s ${
58 | duration > 2000 ? '💔' : '-'
59 | } ${apiEndpoint}`
60 | );
61 | clearTimeout(timeoutId);
62 | // if is not valid JSON, return response
63 | if (!response.headers.get('content-type')?.includes('application/json')) {
64 | return response as T;
65 | }
66 | return (await response.json()) as T;
67 | } catch (error) {
68 | if (error instanceof Error && error.name === 'AbortError') {
69 | throw new Error(
70 | 'Fetch request timed out: ' + (timeout / 1000).toFixed(1) + ' s'
71 | );
72 | }
73 | consoleLog('getApiResponse error:', error);
74 | }
75 |
76 | return null;
77 | };
78 |
--------------------------------------------------------------------------------
/src/utils/shared/og.ts:
--------------------------------------------------------------------------------
1 | type OpenGraphType = {
2 | siteName: string;
3 | description: string;
4 | templateTitle?: string;
5 | logo?: string;
6 | };
7 | // !STARTERCONF This OG is generated from https://github.com/theodorusclarence/og
8 | // Please clone them and self-host if your site is going to be visited by many people.
9 | // Then change the url and the default logo.
10 | export function openGraph({
11 | siteName,
12 | templateTitle,
13 | description,
14 | // !STARTERCONF Or, you can use my server with your own logo.
15 | logo = 'https://og./images/logo.jpg',
16 | }: OpenGraphType): string {
17 | const ogLogo = encodeURIComponent(logo);
18 | const ogSiteName = encodeURIComponent(siteName.trim());
19 | const ogTemplateTitle = templateTitle
20 | ? encodeURIComponent(templateTitle.trim())
21 | : undefined;
22 | const ogDesc = encodeURIComponent(description.trim());
23 |
24 | return `https://og./api/general?siteName=${ogSiteName}&description=${ogDesc}&logo=${ogLogo}${
25 | ogTemplateTitle ? `&templateTitle=${ogTemplateTitle}` : ''
26 | }`;
27 | }
28 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2015",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "baseUrl": ".",
17 | "paths": {
18 | "@/*": ["./src/*"],
19 | "~/*": ["./public/*"]
20 | },
21 | "incremental": true,
22 | "plugins": [
23 | {
24 | "name": "next"
25 | }
26 | ]
27 | },
28 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
29 | "exclude": ["node_modules"],
30 | "moduleResolution": ["node_modules", ".next", "node"]
31 | }
32 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "headers": [
3 | {
4 | "source": "/fonts/inter-var-latin.woff2",
5 | "headers": [
6 | {
7 | "key": "Cache-Control",
8 | "value": "public, max-age=31536000, immutable"
9 | }
10 | ]
11 | }
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------