├── .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 | [![Deploy with Vercel](https://vercel.com/button)](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 | ![image of folder structure](https://raw.githubusercontent.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter/main/public/images/next-mui-folders.png) 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 | ![app/page.tsx code example](https://raw.githubusercontent.com/AlexStack/nextjs-materia-mui-typescript-hook-form-scaffold-boilerplate-starter/main/public/images/app-page-tsx.png) 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 | 31 | 32 | Back to home 33 |
34 | 500 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 | 404 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 | 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 | 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 | 152 | 153 | 154 | 155 | 162 |
Total fetch count from React Context:
163 | 172 | {fetchCount} 173 | 174 |
175 | 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 | 68 | ), 69 | cancelButton: config.cancelButton || ( 70 | 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 | --------------------------------------------------------------------------------