Example Github Page PWA with NextJs, code splitting Redux-Toolkit, Typescript, Eslint, Jest and Emotion.
- Multi-page React Progressive Web App
- Installable for offline use through Chrome on desktop or mobile
- Can be statically hosted on Github Page for free (or as a regular web app hosted on a custom server)
- Dynamically loaded Redux reducers for code splitting
- Prefetch security sensitive content at build time
- All in Typescript/Javascript with CSS-in-JS
- Easy testing with Jest and Enzyme
- Eslint helps practice standard coding styles
- NextJs v9.4.2
- Redux-Toolkit v1.3.6
- Emotion v10
- Typescript v3.9.2
- [Nextjs_Ts_Eslint] NextJs, EmotionJs, Typescript
- [nextjs_redux_toolkit] NextJs, Redux-Toolkit
- [github_sql_pwa] Github page pwa setup with NextJs, code splitting Redux-Toolkit, Sql.js, Typeorm
- setup node env
nvm use npm install
- remove unwanted files in
public/
,src/
- add
.env
and other .env files - preview dev progress on
http://localhost:3000/
npm run dev
- export to
docs/
for Github Page deploynpm run export
- read Setup for notes
- install nvm in the os
-
nvm install node git init
- add
.gitignore
-
node -v > .nvmrc
-
npm init -y
-
npm i -S next react react-dom
- add a script to your package.json like this:
{ "scripts": { "dev": "next", "build": "next build", "start": "next start" } }
-
npm i -D typescript @types/react @types/react-dom @types/node
- create
tsconfig.json
{ "compilerOptions": { "allowJs": true, "allowSyntheticDefaultImports": true, "alwaysStrict": true, "esModuleInterop": true, "isolatedModules": true, "jsx": "preserve", "lib": [ "dom", "es2017" ], "module": "esnext", "moduleResolution": "node", "noEmit": true, "typeRoots": [ "./node_modules/@types" ], "noFallthroughCasesInSwitch": true, "noUnusedLocals": true, "noUnusedParameters": true, "resolveJsonModule": true, "removeComments": false, "skipLibCheck": true, "strict": true, "target": "esnext", "forceConsistentCasingInFileNames": true, "baseUrl": "./src" }, "exclude": [ "node_modules", "next.config.js" ], "include": [ "**/*.ts", "**/*.tsx" ] }
- create
src/pages
folder (orpages
) - create
pages.tsx
undersrc/pages/
(i.e.src/pages/index.tsx
for/
route)
-
npm i -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-plugin-react eslint-import-resolver-typescript npm i -D eslint-config-airbnb eslint-plugin-jsx-a11y eslint-plugin-import eslint-plugin-react-hooks npm i -D prettier eslint-config-prettier eslint-plugin-prettier
- create
.eslintrc.js
module.exports = { parser: '@typescript-eslint/parser', // Specifies the ESLint parser extends: [ 'plugin:react/recommended', // Uses the recommended rules from @eslint-plugin-react 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from @typescript-eslint/eslint-plugin 'airbnb', //Uses airbnb recommended rules 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. ], parserOptions: { ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features sourceType: 'module', // Allows for the use of imports ecmaFeatures: { jsx: true, // Allows for the parsing of JSX }, }, env: { browser: true, node: true }, rules: { // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs // e.g. '@typescript-eslint/explicit-function-return-type': 'off', 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': ['error', { 'vars': 'all', 'args': 'after-used', 'ignoreRestSiblings': false }], 'react/jsx-filename-extension': [1, { 'extensions': ['.js', '.jsx', '.ts', '.tsx'] }], 'react/jsx-first-prop-new-line': 0, '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/explicit-function-return-type': 0, '@typescript-eslint/no-namespace': 'off', 'jsx-a11y/anchor-is-valid': [ 'error', { 'components': [ 'Link' ], 'specialLink': [ 'hrefLeft', 'hrefRight' ], 'aspects': [ 'invalidHref', 'preferButton' ] }], 'react/prop-types': 'off', 'import/extensions': [1, { 'extensions': ['.js', '.jsx', '.ts', '.tsx'] }], 'import/no-extraneous-dependencies': [ 'error', { 'devDependencies': true } ], 'comma-dangle': [ 'error', { 'arrays': 'always-multiline', 'objects': 'always-multiline', 'imports': 'always-multiline', 'exports': 'always-multiline', 'functions': 'never' } ], "react-hooks/rules-of-hooks": "error", 'react-hooks/exhaustive-deps': 'off', 'no-bitwise': 'off' }, plugins: [ '@typescript-eslint/eslint-plugin', 'react-hooks', ], settings: { 'import/resolver': { node: { extensions: ['.js', '.jsx', '.ts', '.tsx'], }, typescript: {}, }, react: { version: 'detect', // Tells eslint-plugin-react to automatically detect the version of React to use }, }, };
- create
.prettierrc.js
module.exports = { semi: true, trailingComma: 'es5', singleQuote: true, printWidth: 80, tabWidth: 2, };
-
npm i -D jest babel-jest
- add scripts in
package.json
"scripts": { "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage" },
-
npm i -D enzyme enzyme-adapter-react-16 enzyme-to-json npm i -D typescript @types/enzyme @types/enzyme-adapter-react-16 @types/jest
- create
jest.config.js
module.exports = { moduleFileExtensions: ['ts', 'tsx', 'js'], testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|js?|tsx?|ts?)$', globals: { NODE_ENV: 'test', }, snapshotSerializers: ['enzyme-to-json/serializer'], transform: { '^.+\\.(j|t)sx?$': 'babel-jest', }, coveragePathIgnorePatterns: [ '/node_modules/', 'jest.setup.js', '<rootDir>/configs/', 'jest.config.js', '.json', '.snap', ], setupFiles: ['<rootDir>/jest/jest.setup.js'], coverageReporters: ['json', 'lcov', 'text', 'text-summary'], moduleNameMapper: { '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/__mocks__/mocks.js', '\\.(css|less|scss)$': '<rootDir>/__mocks__/mocks.js', }, moduleDirectories: ['node_modules', 'src'], };
- create
babel.config.js
module.exports = { presets: ['next/babel'], };
- create
jest/jest.setup.js
import Enzyme from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; import { join } from 'path'; import { loadEnvConfig } from 'next/dist/lib/load-env-config'; // to load '.env' files in test loadEnvConfig(join(__dirname, '.../')); Enzyme.configure({ adapter: new Adapter() });
- change
env
in.eslintrc.js
env: { browser: true, node: true, jest: true },
-
npm i -S @emotion/core npm i -D @emotion/babel-preset-css-prop jest-emotion eslint-plugin-emotion
- change
babel.config.js
module.exports = { presets: [ [ 'next/babel', { 'preset-env': {}, 'preset-react': {}, }, ], '@emotion/babel-preset-css-prop', ], };
- add rules and plugins to
.eslintrc.js
module.exports = { // ... rules: { // ... "emotion/no-vanilla": "error", "emotion/import-from-emotion": "error", "emotion/styled-import": "error", }, // ... plugins: [ 'emotion', // ... ], // ... }
- add
jest/jest.setupAfterEnv.js
import { matchers } from 'jest-emotion'; expect.extend(matchers);
- add serializers and setup files to
jest/jest.config.js
// ... snapshotSerializers: ['enzyme-to-json/serializer', 'jest-emotion'], // ... setupFilesAfterEnv: ['<rootDir>/jest.setupAfterEnv.js'], // ...
(deploy to /docs intead of using gh-pages branch; replace {folder}
with the project name in github repo)
- add
.env.production
NEXT_PUBLIC_LINK_PREFIX=/{folder}
- create
LINK_PREFIX
innext.config.js
const LINK_PREFIX = process.env.NEXT_PUBLIC_LINK_PREFIX || ''; module.exports = () => ({ assetPrefix: LINK_PREFIX, });
- change
as
prop innext/Link
to addlinkPrefix
, similar tosrc/features/link/Link.tsx
in the example setup - change
scripts
inpackage.json
{ "scripts": { "export": "NODE_ENV=production npm run build && next export -o docs && touch docs/.nojekyll" } }
-
npm i -D @babel/plugin-proposal-nullish-coalescing-operator @babel/plugin-proposal-optional-chaining
- add the plugins to
babel.config.js
module.exports = { presets: [ // ... ], plugins: [ '@babel/plugin-proposal-optional-chaining', '@babel/plugin-proposal-nullish-coalescing-operator', ], };
-
npm i -S react-redux @reduxjs/toolkit npm i -D @types/react-redux
- either use
next-redux-wrapper
package (npm i -P next-redux-wrapper
) or copy thewithRedux.tsx
from the example setupsrc/utils/redux
- create custom
makeStore
function,_app.tsx
page and other redux setup as examples innext-redux-wrapper
repo shows
- copy
configureStore.ts
,DynamicStoreWrap.tsx
from the example setupsrc/utils/redux
, andobjectAssign.ts
fromsrc/utils/common
- change
src/_app.tsx
similar to the example setup
-
npm i -S next-pwa next-manifest
- change
next.config.js
const isProd = process.env.NODE_ENV === 'production'; const FOLDER = LINK_PREFIX && LINK_PREFIX.substring(1); // tranfrom precache url for browsers that encode dynamic routes // i.e. "[id].js" => "%5Bid%5D.js" const encodeUriTransform = async (manifestEntries) => { const manifest = manifestEntries.map((entry) => { entry.url = encodeURI(entry.url); return entry; }); return { manifest, warnings: [] }; }; module.exports = () => withManifest( withPWA({ // ... // service worker pwa: { disable: !isProd, subdomainPrefix: LINK_PREFIX, dest: 'public', navigationPreload: true, }, // manifest manifest: { /* eslint-disable @typescript-eslint/camelcase */ output: 'public', short_name: FOLDER, name: FOLDER, start_url: `${LINK_PREFIX}/`, background_color: THEME_COLOR, display: 'standalone', scope: `${LINK_PREFIX}/`, dir: 'ltr', // text direction: left to right theme_color: THEME_COLOR, icons: [ { src: `${LINK_PREFIX}${ICON_192_PATH}`, sizes: '192x192', type: 'image/png', }, { src: `${LINK_PREFIX}${ICON_512_PATH}`, sizes: '512x512', type: 'image/png', }, ], }, }) );
- add
public/icons
folder and include corresponding icon files in the folder - copy
ManifestHead.tsx
from the example setupsrc/features/head
- import
ManifestHead
in pages
- NextJs, next-pwa, workbox are still growing their api, so this project setup will be modified in the future for easier setup.
- There is a known error on the workbox: GoogleChrome/workbox#2178.
- Only direct children in
next/head
will be picked up at build time, so allnext/link
wrapped elements must be inserted (useEffect) after thenext/head
is loaded.