├── .gitignore ├── LICENSE.md ├── README.md ├── components ├── KittenItem.js └── PageHead.js ├── config └── config.js ├── docs └── demo.gif ├── package.json ├── pages ├── _app.js └── index.js ├── redux └── reduxApi.js ├── server ├── api │ └── kittens.js ├── routes.js └── server.js ├── static └── app.css └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .next/ 2 | node_modules/ 3 | .env 4 | 5 | # See https://help.github.com/ignore-files/ for more about ignoring files. 6 | 7 | # dependencies 8 | /node_modules 9 | 10 | # testing 11 | /coverage 12 | 13 | # production 14 | /build 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019, Tom Söderlund 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | 7 | Source: http://opensource.org/licenses/ISC 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js (React) + Redux + Express REST API + Postgres SQL boilerplate 2 | 3 | _Note: this is my v2 boilerplate for React web apps. See also my [Firebase and React Hooks boilerplate](https://github.com/tomsoderlund/nextjs-pwa-firebase-boilerplate) and [REST + MongoDB boilerplate](https://github.com/tomsoderlund/nextjs-express-mongoose-crudify-boilerplate)._ 4 | 5 | ## Support this project 6 | 7 | Did you or your company find `nextjs-sql-rest-api-boilerplate` useful? Please consider giving a small donation, it helps me spend more time on open-source projects: 8 | 9 | [![Support Tom on Ko-Fi.com](https://www.tomsoderlund.com/ko-fi_tomsoderlund_50.png)](https://ko-fi.com/tomsoderlund) 10 | 11 | ## Why is this awesome? 12 | 13 | This is a great starting point for a any project where you want **React + Redux** (with server-side rendering, powered by [Next.js](https://github.com/zeit/next.js)) as frontend and **Express/Postgres SQL** as a REST API backend. 14 | _Lightning fast, all JavaScript._ 15 | 16 | * Simple REST API routes with [`sql-wizard`](https://github.com/tomsoderlund/sql-wizard). 17 | * Redux REST support with `redux-api` and `next-redux-wrapper`. 18 | * Flexible client-side routing with `next-routes` (see `server/routes.js`). 19 | * Flexible configuration with `config/config.js` and `.env` file. 20 | * Hot reloading with `nodemon`. 21 | * Testing with Jasmine. 22 | * Code formatting and linting with StandardJS. 23 | * JWT authentication for client-server communication (coming). 24 | 25 | ## Demo 26 | 27 | See [**nextjs-sql-rest-api-boilerplate** running on Heroku here](https://nextjs-sql-rest-api.herokuapp.com/). 28 | 29 | ![nextjs-sql-rest-api-boilerplate demo on Heroku](docs/demo.gif) 30 | 31 | ## How to use 32 | 33 | Clone this repository: 34 | 35 | git clone https://github.com/tomsoderlund/nextjs-sql-rest-api-boilerplate.git [MY_APP] 36 | 37 | Install dependencies: 38 | 39 | cd [MY_APP] 40 | yarn # or npm install 41 | 42 | Install Postgres and set up the database: 43 | 44 | psql postgres # Start the Postgres command-line client 45 | 46 | CREATE DATABASE "nextjs-sql-rest-api-boilerplate"; -- You can also use \connect to connect to existing database 47 | CREATE TABLE kitten (id serial, name text); -- Create a blank table 48 | INSERT INTO kitten (name) VALUES ('Pugget'); -- Add example data 49 | SELECT * FROM kitten; -- Check data exists 50 | \q 51 | 52 | Start it by doing the following: 53 | 54 | export DATABASE_URL=[your Postgres URL] # Or use a .env file 55 | yarn dev 56 | 57 | In production: 58 | 59 | yarn build 60 | yarn start 61 | 62 | If you navigate to `http://localhost:3123/` you will see a [Next.js](https://github.com/zeit/next.js) page with a list of kittens (or an empty list if you haven’t added one). 63 | 64 | Your API server is running at `http://localhost:3123/api/kittens` 65 | 66 | 67 | ## Deploying 68 | 69 | ### Deploying on Heroku 70 | 71 | heroku create [MY_APP] 72 | heroku addons:create heroku-postgresql:hobby-dev 73 | git push heroku master 74 | 75 | ### Deploying on Now 76 | 77 | (Coming) 78 | -------------------------------------------------------------------------------- /components/KittenItem.js: -------------------------------------------------------------------------------- 1 | const KittenItem = ({ kitten, index, inProgress, handleUpdate, handleDelete }) => ( 2 |
3 | {kitten.name} 4 | Update 5 | Delete 6 | 24 |
25 | ) 26 | export default KittenItem 27 | -------------------------------------------------------------------------------- /components/PageHead.js: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | 3 | const PageHead = ({ title, description }) => ( 4 | 5 | {title} 6 | 7 | 8 | 9 | 10 | 11 | 12 | ) 13 | export default PageHead 14 | -------------------------------------------------------------------------------- /config/config.js: -------------------------------------------------------------------------------- 1 | const appName = 'nextjs-sql-rest-api-boilerplate' 2 | const serverPort = process.env.PORT || 3123 3 | 4 | const completeConfig = { 5 | 6 | default: { 7 | appName, 8 | serverPort, 9 | databaseUrl: process.env.DATABASE_URL || `postgresql://localhost/${appName}`, 10 | jsonOptions: { 11 | headers: { 12 | 'Content-Type': 'application/json' 13 | } 14 | } 15 | }, 16 | 17 | development: { 18 | appUrl: `http://localhost:${serverPort}/` 19 | }, 20 | 21 | production: { 22 | appUrl: `https://nextjs-sql-rest-api.herokuapp.com/` 23 | } 24 | 25 | } 26 | 27 | // Public API 28 | module.exports = { 29 | config: { ...completeConfig.default, ...completeConfig[process.env.NODE_ENV] }, 30 | completeConfig 31 | } 32 | -------------------------------------------------------------------------------- /docs/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomsoderlund/nextjs-sql-rest-api-boilerplate/a6498597582a5a6e8865b7bbbf1ad1feb8f46b34/docs/demo.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-sql-rest-api-boilerplate", 3 | "version": "1.0.1", 4 | "description": "Next.js (React) + Redux + Express REST API + Postgres SQL boilerplate.", 5 | "author": "Tom Söderlund ", 6 | "license": "ISC", 7 | "main": "server/server.js", 8 | "scripts": { 9 | "test": "echo 'Running Standard.js and Jasmine unit tests...\n' && yarn lint && yarn unit", 10 | "unit": "jasmine", 11 | "lint": "standard", 12 | "fix": "standard --fix", 13 | "dev": "nodemon -w server -w package.json server/server.js", 14 | "start": "NODE_ENV=production node server/server.js", 15 | "build": "next build", 16 | "heroku-postbuild": "next build" 17 | }, 18 | "standard": { 19 | "parser": "babel-eslint", 20 | "ignore": [ 21 | ".next/", 22 | "next.config.js" 23 | ], 24 | "globals": [ 25 | "describe", 26 | "expect", 27 | "it" 28 | ] 29 | }, 30 | "engines": { 31 | "node": "^10.13.0", 32 | "yarn": "^1.3.2" 33 | }, 34 | "dependencies": { 35 | "body-parser": "^1.18.3", 36 | "dotenv": "^6.2.0", 37 | "express": "^4.16.4", 38 | "glob": "^7.1.3", 39 | "isomorphic-fetch": "^2.2.1", 40 | "next": "^8.0.3", 41 | "next-redux-wrapper": "^3.0.0-alpha.1", 42 | "next-routes": "^1.4.2", 43 | "pg": "^7.8.1", 44 | "react": "^16.8.3", 45 | "react-dom": "^16.8.3", 46 | "react-redux": "^6.0.1", 47 | "redux": "^4.0.1", 48 | "redux-api": "^0.11.2", 49 | "redux-thunk": "^2.3.0", 50 | "sql-wizard": "^1.0.11" 51 | }, 52 | "devDependencies": { 53 | "babel-eslint": "^10.0.1", 54 | "jasmine": "^3.3.1", 55 | "nodemon": "^1.12.1", 56 | "standard": "^12.0.1" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | // pages/_app.js 2 | import React from 'react' 3 | import { Provider } from 'react-redux' 4 | import App, { Container } from 'next/app' 5 | import withRedux from 'next-redux-wrapper' 6 | import { makeStore } from '../redux/reduxApi.js' 7 | 8 | class MyApp extends App { 9 | static async getInitialProps ({ Component, ctx }) { 10 | return { 11 | pageProps: { 12 | // Call page-level getInitialProps 13 | ...(Component.getInitialProps ? await Component.getInitialProps(ctx) : {}) 14 | } 15 | } 16 | } 17 | 18 | render () { 19 | const { Component, pageProps, store } = this.props 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | ) 27 | } 28 | } 29 | 30 | export default withRedux(makeStore, { debug: false })(MyApp) 31 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | 3 | import reduxApi, { withKittens } from '../redux/reduxApi.js' 4 | 5 | import { Link } from '../server/routes.js' 6 | import PageHead from '../components/PageHead' 7 | import KittenItem from '../components/KittenItem' 8 | 9 | class IndexPage extends Component { 10 | static async getInitialProps ({ store, isServer, pathname, query }) { 11 | // Get all kittens 12 | const kittens = await store.dispatch(reduxApi.actions.kittens.sync()) 13 | return { kittens, query } 14 | } 15 | 16 | constructor (props) { 17 | super(props) 18 | this.state = { name: '', inProgress: false } 19 | } 20 | 21 | handleChangeInputText (event) { 22 | this.setState({ name: event.target.value }) 23 | } 24 | 25 | handleAdd (event) { 26 | const { name } = this.state 27 | if (!name) return 28 | const callbackWhenDone = () => this.setState({ name: '', inProgress: false }) 29 | this.setState({ inProgress: true }) 30 | // Actual data request 31 | const newKitten = { name } 32 | this.props.dispatch(reduxApi.actions.kittens.post({}, { body: JSON.stringify(newKitten) }, callbackWhenDone)) 33 | } 34 | 35 | handleUpdate (kitten, index, kittenId, event) { 36 | const name = window.prompt('New name?', kitten.name) 37 | if (!name) return 38 | const callbackWhenDone = () => this.setState({ inProgress: false }) 39 | this.setState({ inProgress: kittenId }) 40 | // Actual data request 41 | const newKitten = { id: kittenId, name } 42 | this.props.dispatch(reduxApi.actions.kittens.put({ id: kittenId }, { body: JSON.stringify(newKitten) }, callbackWhenDone)) 43 | } 44 | 45 | handleDelete (index, kittenId, event) { 46 | const callbackWhenDone = () => this.setState({ inProgress: false }) 47 | this.setState({ inProgress: kittenId }) 48 | // Actual data request 49 | this.props.dispatch(reduxApi.actions.kittens.delete({ id: kittenId }, callbackWhenDone)) 50 | } 51 | 52 | render () { 53 | const { kittens } = this.props 54 | 55 | const kittenList = kittens.data 56 | ? kittens.data.map((kitten, index) => ) 64 | : [] 65 | 66 | return
67 | 71 | 72 |

Kittens

73 | 74 | {kittenList} 75 |
76 | 77 | 78 | 83 |
84 | 85 |

Routing

86 | Current page slug: /{this.props.query.slug} 87 | 91 | 92 |
93 | }; 94 | } 95 | 96 | export default withKittens(IndexPage) 97 | -------------------------------------------------------------------------------- /redux/reduxApi.js: -------------------------------------------------------------------------------- 1 | import { get } from 'lodash' 2 | import fetch from 'isomorphic-fetch' 3 | 4 | import reduxApi, { transformers } from 'redux-api' 5 | import adapterFetch from 'redux-api/lib/adapters/fetch' 6 | import { createStore, applyMiddleware, combineReducers } from 'redux' 7 | import thunkMiddleware from 'redux-thunk' 8 | import { connect } from 'react-redux' 9 | 10 | const { config } = require('../config/config') 11 | 12 | const apiTransformer = function (newItem, oldItems, action) { 13 | const actionMethod = get(action, 'request.params.method') 14 | switch (actionMethod) { 15 | case 'POST': 16 | return [...oldItems, newItem] 17 | case 'PUT': 18 | return oldItems.map(oldItem => oldItem.id === newItem.id ? Object.assign({}, oldItem, newItem) : oldItem) 19 | case 'DELETE': 20 | return oldItems.filter(oldItem => oldItem.id !== newItem.id) 21 | default: 22 | return transformers.array.call(this, newItem, oldItems, action) 23 | } 24 | } 25 | 26 | // redux-api documentation: https://github.com/lexich/redux-api/blob/master/docs/DOCS.md 27 | const thisReduxApi = reduxApi({ 28 | 29 | // Simple endpoint description 30 | // oneKitten: '/api/kittens/:id', 31 | 32 | // Complex endpoint description 33 | kittens: { 34 | url: '/api/kittens/:id', 35 | crud: true, // Make CRUD actions: https://github.com/lexich/redux-api/blob/master/docs/DOCS.md#crud 36 | 37 | // base endpoint options `fetch(url, options)` 38 | options: config.jsonOptions, 39 | 40 | // reducer (state, action) { 41 | // console.log('reducer', action); 42 | // return state; 43 | // }, 44 | 45 | // postfetch: [ 46 | // function ({data, actions, dispatch, getState, request}) { 47 | // console.log('postfetch', {data, actions, dispatch, getState, request}); 48 | // dispatch(actions.kittens.sync()); 49 | // } 50 | // ], 51 | 52 | // Reimplement default `transformers.object` 53 | // transformer: transformers.array, 54 | transformer: apiTransformer 55 | 56 | } 57 | 58 | }) 59 | .use('fetch', adapterFetch(fetch)) 60 | .use('rootUrl', config.appUrl) 61 | 62 | export default thisReduxApi 63 | 64 | const createStoreWithThunkMiddleware = applyMiddleware(thunkMiddleware)(createStore) 65 | export const makeStore = (reduxState, enhancer) => createStoreWithThunkMiddleware(combineReducers(thisReduxApi.reducers), reduxState) 66 | 67 | // endpointNames: Use reduxApi endpoint names here 68 | const mapStateToProps = (endpointNames, reduxState) => { 69 | let props = {} 70 | for (let i in endpointNames) { 71 | props[endpointNames[i]] = reduxState[endpointNames[i]] 72 | props[`${endpointNames[i]}Actions`] = thisReduxApi.actions[endpointNames[i]] 73 | } 74 | return props 75 | } 76 | 77 | export const withReduxEndpoints = (PageComponent, endpointNames) => connect(mapStateToProps.bind(undefined, endpointNames))(PageComponent) 78 | // Define custom endpoints/providers here: 79 | export const withKittens = PageComponent => withReduxEndpoints(PageComponent, ['kittens']) 80 | -------------------------------------------------------------------------------- /server/api/kittens.js: -------------------------------------------------------------------------------- 1 | const { routes: { createSqlRestRoutes } } = require('sql-wizard') 2 | 3 | module.exports = (server, pool) => { 4 | createSqlRestRoutes(server, pool, '/api/kittens', 'kitten', { /* place custom REST handlers here */ }) 5 | } 6 | -------------------------------------------------------------------------------- /server/routes.js: -------------------------------------------------------------------------------- 1 | const routes = require('next-routes') 2 | const routesImplementation = routes() 3 | 4 | // routesImplementation 5 | // .add([identifier], pattern = /identifier, page = identifier) 6 | // .add('/blog/:slug', 'blogShow') 7 | // .add('showBlogPostRoute', '/blog/:slug', 'blogShow') 8 | 9 | routesImplementation.add('/:slug', 'index') 10 | routesImplementation.add('/more/:slug', 'index') 11 | 12 | module.exports = routesImplementation 13 | 14 | // Usage inside Page.getInitialProps (req = { pathname, asPath, query } = { pathname: '/', asPath: '/about', query: { slug: 'about' } }) 15 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | 3 | const bodyParser = require('body-parser') 4 | const glob = require('glob') 5 | const next = require('next') 6 | const server = require('express')() 7 | const { Pool } = require('pg') 8 | 9 | const dev = process.env.NODE_ENV !== 'production' 10 | const app = next({ dev }) 11 | 12 | const routes = require('./routes') 13 | const routerHandler = routes.getRequestHandler(app) 14 | 15 | const { config } = require('../config/config') 16 | 17 | app.prepare().then(() => { 18 | // Parse application/x-www-form-urlencoded 19 | server.use(bodyParser.urlencoded({ extended: false })) 20 | // Parse application/json 21 | server.use(bodyParser.json()) 22 | 23 | // Allows for cross origin domain request: 24 | server.use(function (req, res, next) { 25 | res.header('Access-Control-Allow-Origin', '*') 26 | res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept') 27 | res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, DELETE') 28 | next() 29 | }) 30 | 31 | // Postgres (pg) 32 | const pool = new Pool({ connectionString: config.databaseUrl }) 33 | 34 | // REST API routes 35 | const rootPath = require('path').join(__dirname, '/..') 36 | glob.sync(rootPath + '/server/api/*.js').forEach(controllerPath => { 37 | if (!controllerPath.includes('.test.js')) require(controllerPath)(server, pool) 38 | }) 39 | 40 | // Next.js page routes 41 | server.get('*', routerHandler) 42 | 43 | // Start server 44 | server.listen(config.serverPort, () => console.log(`${config.appName} running on http://localhost:${config.serverPort}/`)) 45 | }) 46 | -------------------------------------------------------------------------------- /static/app.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | margin: 1em; 7 | font-family: sans-serif; 8 | font-size: 20px; 9 | } 10 | 11 | @media only screen and (max-width: 480px) { 12 | input, button { 13 | width: 100%; 14 | } 15 | } 16 | 17 | /* Nice & simple: Button - http://codepen.io/tomsoderlund/pen/qqyzqp */ 18 | button { 19 | background-color: dodgerblue; 20 | border-radius: 0.2em; 21 | border: none; 22 | box-shadow: 0 0.125em 0.125em rgba(0,0,0, 0.3); 23 | box-sizing: border-box; 24 | color: white; 25 | cursor: pointer; 26 | font-family: inherit; 27 | font-size: inherit; 28 | font-weight: bold; 29 | outline: none; 30 | padding: 0.6em; 31 | margin: 0.2em; 32 | transition: all 0.2s; 33 | } 34 | button:hover:not(:disabled) { 35 | opacity: 0.8; 36 | transition: box-shadow 0s; 37 | } 38 | button:active { 39 | margin-top: 0.3em; 40 | margin-bottom: 0.1em; 41 | box-shadow: 0 0.5px 0.125em rgba(0,0,0, 0.4); 42 | } 43 | button:disabled { 44 | background-color: silver; 45 | } 46 | 47 | /* Nice & simple: Input and Dropdown Menu - http://codepen.io/tomsoderlund/pen/GNBbWz */ 48 | input, 49 | textarea, 50 | select { 51 | outline: none; 52 | resize: none; 53 | box-shadow: inset 0 0.125em 0.125em rgba(0,0,0, 0.3); 54 | box-sizing: border-box; 55 | background-color: white; 56 | border-radius: 0.2em; 57 | border: 2px solid lightgray; 58 | color: inherit; 59 | font-family: inherit; 60 | font-size: inherit; 61 | padding: 0.6em; 62 | margin: 0.2em; 63 | } 64 | input:hover:not(:disabled), 65 | textarea:hover:not(:disabled), 66 | select:hover:not(:disabled) { 67 | border-color: silver; 68 | } 69 | input:focus, 70 | textarea:focus, 71 | select:focus { 72 | border-color: darkgray; 73 | } 74 | input:disabled, 75 | textarea:disabled, 76 | select:disabled { 77 | background-color: whitesmoke; 78 | } --------------------------------------------------------------------------------