├── .babelrc ├── .env ├── .gitignore ├── .travis.yml ├── README.md ├── next.config.js ├── now.json ├── package.json ├── pages ├── _app.js ├── _document.js ├── about.js └── index.js ├── public └── static │ └── next-logo.png ├── server └── index.js ├── src ├── components │ ├── Layout.js │ └── SearchResults.js ├── config.js ├── features │ └── repoSearch │ │ ├── RepoSearch.js │ │ └── repoSearchSlice.js ├── libs │ └── github.js ├── store.js ├── tests │ ├── components │ │ └── SearchResults.test.js │ └── test-utils.js └── theme.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "next/babel" 4 | ], 5 | "plugins": [ 6 | "inline-dotenv", 7 | ["styled-components", { "ssr": true }], 8 | ["module-resolver", { 9 | "root": ["./src"] 10 | }] 11 | ], 12 | "env": { 13 | "test": { 14 | "presets": [ 15 | ["next/babel", { 16 | "preset-env": { 17 | "modules": "commonjs" 18 | } 19 | }] 20 | ] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | MODE=development 3 | GITHUB_API_ENDPOINT=https://api.github.com 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .next/ 3 | *.log 4 | *.swp 5 | *.swo 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10.16.0" 4 | script: npm run test:ci 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js redux starter 2 | An opinionated Next.js starter kit with Express, Redux Toolkit, styled-components, and react-testing-library. 3 | 4 | [![Build Status](https://travis-ci.org/CodementorIO/nextjs-redux-starter.svg?branch=master)](https://travis-ci.org/CodementorIO/nextjs-redux-starter) 5 | [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) [![Greenkeeper badge](https://badges.greenkeeper.io/CodementorIO/nextjs-redux-starter.svg)](https://greenkeeper.io/) 6 | 7 | ## About 8 | Next.js is an awesome and minimalistic framework to make a modern universal react app. However, there're times that we need a bit more features to build a complex SPA. That's why this project is born. 9 | 10 | ## Features 11 | - ▲ Based on latest [Next.js](https://github.com/zeit/next.js) 12 | - 🗄 State management with [redux-toolkit](https://github.com/reduxjs/redux-toolkit) 13 | - 💅 Styling with [styled-components](https://github.com/styled-components/styled-components) 14 | - 🐐 Unit testing with [react-testing-library](https://github.com/testing-library/react-testing-library) 15 | - 🛀 Linting staged changes on [pre-commit](https://github.com/pre-commit/pre-commit) with [standard](https://github.com/standard/standard) 16 | - ⛑ [react-helmet](https://github.com/nfl/react-helmet), [dotenv](https://github.com/motdotla/dotenv), and more... 17 | 18 | ## Getting started 19 | ``` 20 | git clone https://github.com/kiddodev050/nextjs-redux-starter my-project 21 | cd my-project 22 | yarn install 23 | yarn start 24 | ``` 25 | 26 | Then open `http://localhost:3100/` to see your app. 27 | 28 | ### Deployment 29 | After `npm run build` finished, run 30 | 31 | ``` 32 | yarn serve 33 | ``` 34 | 35 | If you prefer using `now`, just modify `now.json` config. 36 | 37 | ## Structure overview 38 | ``` 39 | ├── README.md 40 | ├── next.config.js 41 | ├── now.json 42 | ├── package.json 43 | ├── pages 44 | │   ├── _app.js 45 | │   ├── _document.js 46 | │   ├── about.js 47 | │   └── index.js 48 | ├── public 49 | │   └── static 50 | ├── server 51 | │   └── index.js 52 | ├── src 53 | │   ├── components 54 | │   ├── config.js 55 | │   ├── features 56 | │   ├── libs 57 | │   ├── store.js 58 | │   ├── tests 59 | │   │   ├── components 60 | │   │   └── test-utils.js 61 | │   └── theme.js 62 | └── yarn.lock 63 | ``` 64 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const { ASSET_HOST } = process.env 2 | 3 | // for those who using CDN 4 | const assetPrefix = ASSET_HOST || '' 5 | 6 | module.exports = { 7 | assetPrefix, 8 | webpack: (config, { dev }) => { 9 | config.output.publicPath = `${assetPrefix}${config.output.publicPath}` 10 | 11 | return config 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-redux-starter", 3 | "public": true, 4 | "version": 2, 5 | "builds": [{ 6 | "src": "next.config.js", 7 | "use": "@now/next" 8 | }], 9 | "alias": "nextjs-redux-starter.now.sh", 10 | "env": { 11 | "GITHUB_API_ENDPOINT": "https://api.github.com" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@reduxjs/toolkit": "^1.6.2", 4 | "axios": "^0.19.0", 5 | "compression": "^1.7.1", 6 | "express": "^4.16.2", 7 | "helmet": "^3.20.0", 8 | "humps": "^2.0.1", 9 | "next": "^11.1.2", 10 | "prop-types": "^15.7.2", 11 | "react": "^17.0.2", 12 | "react-dom": "^17.0.2", 13 | "react-helmet": "^6.1.0", 14 | "react-redux": "^7.2.5", 15 | "redux-logger": "^3.0.6", 16 | "styled-components": "^5.3.1", 17 | "styled-normalize": "^8.0.6" 18 | }, 19 | "name": "nextjs-redux-starter", 20 | "version": "1.0.0", 21 | "main": "server/index.js", 22 | "devDependencies": { 23 | "@babel/core": "^7.1.0", 24 | "@testing-library/jest-dom": "^5.1.1", 25 | "@testing-library/react": "^9.4.1", 26 | "babel-core": "7.0.0-bridge.0", 27 | "babel-eslint": "^10.0.1", 28 | "babel-jest": "^25.1.0", 29 | "babel-plugin-inline-dotenv": "^1.1.2", 30 | "babel-plugin-module-resolver": "^4.0.0", 31 | "jest": "^25.1.0", 32 | "lint-staged": "^10.0.8", 33 | "nodemon": "^2.0.1", 34 | "pre-commit": "^1.2.2", 35 | "react-test-renderer": "^16.2.0", 36 | "rimraf": "^3.0.0", 37 | "snazzy": "^8.0.0", 38 | "standard": "^13.0.1" 39 | }, 40 | "scripts": { 41 | "lint-staged": "lint-staged", 42 | "build": "NODE_ENV=production next build", 43 | "test:ci": "jest --maxWorkers=2 --ci", 44 | "test": "jest --watch", 45 | "start": "nodemon -w server server/index.js", 46 | "serve": "NODE_ENV=production node server/index.js", 47 | "clean": "rimraf node_modules/.cache .next", 48 | "lint": "standard --verbose | snazzy", 49 | "now-start": "yarn serve", 50 | "lint:fix": "standard --fix --verbose | snazzy" 51 | }, 52 | "pre-commit": "lint-staged", 53 | "lint-staged": { 54 | "*.js": [ 55 | "yarn lint:fix", 56 | "git add" 57 | ] 58 | }, 59 | "standard": { 60 | "parser": "babel-eslint", 61 | "env": [ 62 | "jest" 63 | ] 64 | }, 65 | "engines": { 66 | "node": ">= 10" 67 | }, 68 | "jest": { 69 | "modulePaths": [ 70 | "./src", 71 | "./src/test" 72 | ], 73 | "setupFilesAfterEnv": [ 74 | "@testing-library/jest-dom/extend-expect" 75 | ] 76 | }, 77 | "repository": "CodementorIO/nextjs-redux-starter", 78 | "bugs": "https://github.com/CodementorIO/nextjs-redux-starter/issues", 79 | "author": "Ian Wang (https://github.com/IanWang)", 80 | "license": "ISC", 81 | "description": "Opinionated Next.js starter with Express, Redux, styled-components, and Jest." 82 | } 83 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import { ThemeProvider, createGlobalStyle } from 'styled-components' 2 | import { Helmet } from 'react-helmet' 3 | import { Provider } from 'react-redux' 4 | import styledNormalize from 'styled-normalize' 5 | 6 | import { useStore } from 'store' 7 | import Layout from 'components/Layout' 8 | import theme from 'theme' 9 | 10 | const GlobalStyle = createGlobalStyle` 11 | ${styledNormalize} 12 | ` 13 | 14 | export default function MyApp (props) { 15 | const { Component, pageProps } = props 16 | const store = useStore(pageProps.state) 17 | const title = 'Hello next.js Real World!' 18 | return ( 19 | <> 20 | 21 | {title} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /pages/_document.js: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from 'next/document' 2 | import { ServerStyleSheet } from 'styled-components' 3 | import { Helmet } from 'react-helmet' 4 | 5 | // from https://github.com/zeit/next.js/edit/canary/examples/with-react-helmet/pages/_document.js 6 | export default class extends Document { 7 | static async getInitialProps (ctx) { 8 | const sheet = new ServerStyleSheet() 9 | const originalRenderPage = ctx.renderPage 10 | try { 11 | ctx.renderPage = () => 12 | originalRenderPage({ 13 | enhanceApp: App => props => sheet.collectStyles() 14 | }) 15 | 16 | const documentProps = await Document.getInitialProps(ctx) 17 | return { 18 | ...documentProps, 19 | helmet: Helmet.renderStatic(), 20 | styles: ( 21 | <> 22 | {documentProps.styles} 23 | {sheet.getStyleElement()} 24 | 25 | ) 26 | } 27 | } finally { 28 | sheet.seal() 29 | } 30 | } 31 | 32 | get helmetHtmlAttrComponents () { 33 | return this.props.helmet.htmlAttributes.toComponent() 34 | } 35 | 36 | get helmetBodyAttrComponents () { 37 | return this.props.helmet.bodyAttributes.toComponent() 38 | } 39 | 40 | get helmetHeadComponents () { 41 | return Object.keys(this.props.helmet) 42 | .filter(el => el !== 'htmlAttributes' && el !== 'bodyAttributes') 43 | .map(el => this.props.helmet[el].toComponent()) 44 | } 45 | 46 | render () { 47 | return ( 48 | 49 | 50 | { this.helmetJsx } 51 | { this.helmetHeadComponents } 52 | 53 | 54 |
55 | 56 | 57 | 58 | ) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /pages/about.js: -------------------------------------------------------------------------------- 1 | const About = () =>

About

2 | export default About 3 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import RepoSearch from 'features/repoSearch/RepoSearch' 2 | import { useRouter } from 'next/router' 3 | import { createStore } from 'store' 4 | import { getReposAsync } from 'features/repoSearch/repoSearchSlice' 5 | 6 | const IndexPage = () => { 7 | const router = useRouter() 8 | return ( 9 | <> 10 |
router.push('/about')}> 11 | GO TO ABOUT (with router) 12 |
13 | 14 | 15 | ) 16 | } 17 | 18 | export async function getStaticProps () { 19 | const store = createStore() 20 | await store.dispatch(getReposAsync('python')) 21 | 22 | return { 23 | props: { 24 | state: store.getState() 25 | } 26 | } 27 | } 28 | 29 | export default IndexPage 30 | -------------------------------------------------------------------------------- /public/static/next-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devmad119/nextjs-redux-starter/5ac047e561081945f98f86325aa5225816ca656b/public/static/next-logo.png -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const express = require('express') 3 | const compression = require('compression') 4 | const next = require('next') 5 | const helmet = require('helmet') 6 | 7 | const port = parseInt(process.env.PORT, 10) || 3100 8 | const dev = process.env.NODE_ENV !== 'production' 9 | const app = next({ dev }) 10 | const handler = app.getRequestHandler() 11 | 12 | app.prepare().then(() => { 13 | const server = express() 14 | 15 | server.use(helmet()) 16 | server.use(compression()) 17 | 18 | const staticPath = path.join(__dirname, '../static') 19 | server.use('/static', express.static(staticPath, { 20 | maxAge: '30d', 21 | immutable: true 22 | })) 23 | 24 | server.get('*', (req, res) => { 25 | return handler(req, res) 26 | }) 27 | 28 | startServer() 29 | 30 | function startServer () { 31 | server.listen(port, () => { 32 | console.log(`> Ready on http://localhost:${port}`) 33 | }) 34 | } 35 | }) 36 | -------------------------------------------------------------------------------- /src/components/Layout.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react' 2 | import Link from 'next/link' 3 | 4 | class Layout extends PureComponent { 5 | render () { 6 | return ( 7 |
8 |
9 | 10 |

nextjs redux starter

11 | 12 | 13 | About 14 | 15 | 16 | Redux demo 17 | 18 | 19 | 36 |
37 | { this.props.children } 38 |
39 | ) 40 | } 41 | } 42 | 43 | export default Layout 44 | -------------------------------------------------------------------------------- /src/components/SearchResults.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | const Item = styled.li` 5 | background: #eee; 6 | padding: 4px 12px; 7 | :hover { 8 | a { 9 | color: #eee; 10 | } 11 | background: ${props => props.theme.colors.primary}; 12 | } 13 | ` 14 | 15 | const REPO_COUNT = 10 16 | const SearchResults = ({ language, repos, totalRepoCount }) => { 17 | return ( 18 | <> 19 |

Top { REPO_COUNT } { language } repos

20 | 31 | { totalRepoCount } repos found 32 | 33 | ) 34 | } 35 | 36 | export default SearchResults 37 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | env: process.env.NODE_ENV, 3 | mode: process.env.MODE, 4 | githubApiEndpoint: process.env.GITHUB_API_ENDPOINT 5 | } 6 | -------------------------------------------------------------------------------- /src/features/repoSearch/RepoSearch.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { selectRepoSearch, getReposAsync } from 'features/repoSearch/repoSearchSlice' 4 | import SearchResults from 'components/SearchResults' 5 | import { useAppDispatch, useAppSelector } from 'store' 6 | 7 | const languages = ['javascript', 'python', 'ruby'] 8 | 9 | const RepoSearch = () => { 10 | const dispatch = useAppDispatch() 11 | const store = useAppSelector(selectRepoSearch) 12 | 13 | return ( 14 | 15 | 19 |
20 | {store.status === 'loading' && 🦆...} 21 |

Switch language

22 | 23 | {languages.map((lang) => { 24 | const getRepos = () => dispatch(getReposAsync(lang)) 25 | return 26 | })} 27 | 28 |
29 | ) 30 | } 31 | 32 | const Container = styled.div` 33 | width: 300px; 34 | padding: 1em; 35 | margin: 1em auto; 36 | position: relative; 37 | ` 38 | 39 | const Languages = styled.div` 40 | display: flex; 41 | ` 42 | 43 | const Option = styled.div` 44 | cursor: pointer; 45 | background: #eee; 46 | flex: 1; 47 | margin: 4px; 48 | padding: 12px; 49 | border: 4px; 50 | text-align: center; 51 | ` 52 | 53 | const Overlay = styled.div` 54 | position: absolute; 55 | top: 0; 56 | left: 0; 57 | background: #fff; 58 | opacity: .9; 59 | width: 100%; 60 | height: 76%; 61 | display: flex; 62 | align-items: center; 63 | justify-content: center; 64 | font-size: 4em; 65 | ` 66 | 67 | export default RepoSearch 68 | -------------------------------------------------------------------------------- /src/features/repoSearch/repoSearchSlice.js: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, createSlice } from '@reduxjs/toolkit' 2 | import github from 'libs/github' 3 | 4 | const initialState = { 5 | language: 'javascript', 6 | repos: [], 7 | totalRepoCount: 0, 8 | status: 'idle' 9 | } 10 | 11 | export const getReposAsync = createAsyncThunk( 12 | 'repoSearch/getRepos', 13 | async (language) => { 14 | const response = await github.getTopRepos({ language }) 15 | return response 16 | } 17 | ) 18 | 19 | const repoSearchSlice = createSlice({ 20 | name: 'repoSearch', 21 | initialState, 22 | reducers: {}, 23 | extraReducers: (builder) => { 24 | builder 25 | .addCase(getReposAsync.pending, (state, action) => { 26 | state.status = 'loading' 27 | }) 28 | .addCase(getReposAsync.fulfilled, (state, action) => { 29 | state.status = 'idle' 30 | state.language = action.meta.arg 31 | state.totalRepoCount = action.payload.totalCount 32 | state.repos = action.payload.items 33 | }) 34 | } 35 | }) 36 | 37 | export const selectRepoSearch = (state) => state.repoSearch 38 | 39 | export const repoSearchReducer = repoSearchSlice.reducer 40 | -------------------------------------------------------------------------------- /src/libs/github.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import humps from 'humps' 3 | import config from 'config' 4 | 5 | const github = { 6 | getTopRepos ({ language = 'javascript' }) { 7 | const path = `${config.githubApiEndpoint}/search/repositories?q=language:${language}&sort=stars&order=desc` 8 | return axios.get(path).then(res => { 9 | return humps.camelizeKeys(res.data) 10 | }) 11 | } 12 | } 13 | 14 | export default github 15 | -------------------------------------------------------------------------------- /src/store.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit' 2 | import { useDispatch, useSelector } from 'react-redux' 3 | import { createLogger } from 'redux-logger' 4 | import config from 'config' 5 | import { repoSearchReducer } from 'features/repoSearch/repoSearchSlice' 6 | 7 | export const createStore = (preloadedState) => { 8 | const middlewares = [] 9 | 10 | if (config.env === 'development' && typeof window !== 'undefined') { 11 | const logger = createLogger({ 12 | level: 'info', 13 | collapsed: true 14 | }) 15 | 16 | middlewares.push(logger) 17 | } 18 | 19 | return configureStore({ 20 | reducer: { 21 | repoSearch: repoSearchReducer 22 | }, 23 | preloadedState, 24 | middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(...middlewares), 25 | devTools: config.env === 'development' 26 | }) 27 | } 28 | 29 | let store 30 | const initializeStore = (preloadedState) => { 31 | let _store = store || createStore(preloadedState) 32 | 33 | if (preloadedState && store) { 34 | _store = createStore({ ...store.getState(), ...preloadedState }) 35 | store = undefined 36 | } 37 | 38 | if (typeof window === 'undefined') { 39 | return _store 40 | } 41 | 42 | if (!store) { 43 | store = _store 44 | } 45 | 46 | return store 47 | } 48 | 49 | export const useStore = (preloadedState) => initializeStore(preloadedState) 50 | 51 | export const useAppDispatch = () => useDispatch() 52 | 53 | export const useAppSelector = useSelector 54 | -------------------------------------------------------------------------------- /src/tests/components/SearchResults.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'test-utils' 3 | import SearchResults from 'components/SearchResults' 4 | 5 | describe('Components/SearchResults', () => { 6 | let props 7 | beforeEach(() => { 8 | props = { 9 | language: 'lang', 10 | totalRepoCount: 2, 11 | repos: [{ 12 | id: 1, 13 | name: 'repo 1', 14 | htmlUrl: 'url 1' 15 | }, { 16 | id: 2, 17 | name: 'repo 2', 18 | htmlUrl: 'url 2' 19 | }] 20 | } 21 | }) 22 | 23 | const setup = () => { 24 | const utils = render() 25 | return utils 26 | } 27 | 28 | it('renders all items', () => { 29 | const utils = setup() 30 | props.repos.forEach((repo) => { 31 | expect(utils.getByText(repo.name)).toHaveAttribute( 32 | 'href', 33 | repo.htmlUrl 34 | ) 35 | }) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /src/tests/test-utils.js: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import { ThemeProvider } from 'styled-components' 3 | import theme from 'theme' 4 | 5 | const WithProviders = ({ children }) => { 6 | return ( 7 | 8 | {children} 9 | 10 | ) 11 | } 12 | 13 | const customRender = (ui, options) => render(ui, { wrapper: WithProviders, ...options }) 14 | 15 | export * from '@testing-library/react' 16 | export { customRender as render } 17 | -------------------------------------------------------------------------------- /src/theme.js: -------------------------------------------------------------------------------- 1 | export default { 2 | colors: { 3 | primary: '#003648' 4 | } 5 | } 6 | --------------------------------------------------------------------------------