This app is a boilerplate for a Postgres/Express/React/Node app with basic authentication built in, using Passport and sessions.
- pg-promise for database connection
- Redux for state management
- Passport, express-sessions, bcrypt for authentication
- Create React App for bootstrapping the React app
Here is a high-level overview of our file structure.
.
├── README.md
├── api/ # routes, models, controllers, middleware, and utilities for express
├── client/ # all things React
├── db/ # database setup and migrations
├── public/ # will contain the production-ready React app
└── server.js To get started, simply clone this repository and follow the below instructions. (If you'd like to get rid of the reference back to this git repository, run the command rm -rf .git.)
- Node 8 or later, along with yarn or npm.
- PostgreSQL
This app is looking for a file .env at the root of the repository, on the same level as package.json. You'll need to create that file and add three values:
SECRET_KEY=[string of gibberish]
PORT=3001
LOCAL_DATABASE_NAME=[name of your local database]
The secret key is used in the passport setup, and the local database name is used for the pg-promise setup.
The port number does not need to be 3001. However, if you need to change the port for any reason, you must also make a change to the package.json in the client directory. There is a field "proxy": "http://localhost:3001" -- the port here must match the port the Express app is running on.
- Run
npm installoryarn installin both the root directory and inclient. - Create a PostgreSQL database using the
createdbcommand. - Create the
.envfile as mentioned above. - Run the migration file in
db/migrationsusing the commandpsql -d [your_new_db] -f [path_to_file].- It might be a good idea at this point to rename the migration file so it reflects the date/time you're running it at.
- As written, the migration file contains fields username, password_digest, and email. You might need to add more for your particular needs -- you should do that now.
- In one terminal instance, run
npm startoryarn start(ornpm run devoryarn dev) - In another terminal instance, run
npm startoryarn start - The React app will be running on port 3000, and the Express app will be running on port 3001.
You should not attempt to deploy this app in a two-server form. Instead, use the predeploy script (npm run predeploy) from the root directory. This will create a production build of the React app, using the build utility provided with create-react-app, and more it into the public directory of the Express app. In this way, the React app is served directly from the Express app.
Instead of following a traditional MVC file structure, this app makes use of a "domain" structure. Each domain-specific folder contains a model, a controller, middlewares, and routes specific to that domain.
For example, for a todo app, the file tree might resemble this:
.
├── api
│ ├── index.js # Routes, sets up `/api/todos` and `/api/users` endpoints
│ ├── todos
│ │ ├── Todo.js # model
│ │ ├── TodoSchema.js
│ │ ├── controller.js
│ │ ├── index.js # routes
│ │ └── middlewares.js
│ ├── users
│ │ ├── User.js # model
│ │ ├── UserSchema.js
│ │ ├── controller.js
│ │ ├── index.js # routes
│ │ └── middlewares.js
│ └── utils # utilities
│ └── index.js
└── server.js The models in this app are prototype-based, and have a specific set of described behavior.
- One can call
ModelName.methodfor actions that reference the collection of data as a whole.- For example, one might find a todo of a specific ID by calling
Todo.findById(id), or find all todos by callingTodo.findAll(). - These collection-level methods are available on the model itself, but not on instances of that model.
- Deletion is considered a collection-level action.
- For example, one might find a todo of a specific ID by calling
- One can use the syntax
new ModelNameto create a new instance of the model, which has methods that are specific to one record in the collection.- For example, one might create a new todo record and save it by saying
new Todo(todoValues).save(), or update a todo by sayingmyTodoInstance.update(changes). - These record-level methods are available on instances of the model, but not on the model itself.
- For example, one might create a new todo record and save it by saying
- When a new instance is created using
new ModelName, the values are verified against a preset schema before any action occurs. - Instances are not modified directly (i.e.
instance.prop = 'thing') but instead alter themselves (i.e.instance._modify({ prop: thing })).
The utils directory within the api directory contains some utility functions to help us set this functionality up.
Let's say we wanted to build out this todo app in earnest. To create our todo model, we'd first need to set up our todo domain: a todos folder within api. Then, we'd create two files: Todo.js and TodoSchema.js.
Within TodoSchema.js, we're going to describe the schema we want to use for each todo. The schema is an array of objects, which contain the following properties:
key: The name of the property (most commonly the column name)type: The datatype we're expecting for this valueoptional: A boolean describing whether or not the value is optional (defaults tofalse)regexpandregexpMessage: If you need the value to match a regular expression (i.e. no spaces, email address, url), use theregexpfield.regexpMessageis used to describe the regular expression's purpose (i.e. "Must be a valid email address".)otherConditionandotherConditionMessage:otherConditionneeds to be a method that accepts a value and returns either true or false (i.e. the value needs to be one of a specified set, etc).otherConditionMessageis used to describe the condition's purpose.
Our TodoSchema might thus look like this:
module.exports = [
{
key: 'id',
type: 'number',
optional: true // when the user inputs a todo and we first validate it, it has no ID
}, {
key: 'title',
type: 'string'
}, {
key: 'description',
type: 'string'
}, {
key: 'category',
type: 'string',
otherCondition: (val) => ['Home', 'Work', 'School', 'Personal'].includes(val),
otherConditionMessage: 'Category must be Home, Work, School, or Personal.'
}
]In Todo.js, we first need to import our database. Then, we write a constructor function that describes a new Todo:
const db = require('../../db/config')
function Todo({ id = null, title, description, category }) {
this.id = this._validate(id, 'id')
this.title = this._validate(title, 'title')
this.description = this._validate(description, 'description')
this.category = this._validate(category, 'category')
}Note that we're passing an object in as an argument (using ES6 destructuring), and that we're using a method _validate to validate the properties we're recieving from the user. We get the _validate method from utils and apply it like so:
const TodoSchema = require('./TodoSchema')
const { modelUtils, modelStatics } = require('../utils')
Todo.prototype = Object.assign(Todo.prototype, modelUtils(TodoSchema))This gives us _modify and _validate attached to Todo.prototype, which means they'll be available to any instance of Todo.
We also need to attach the collection-level methods (findAll, findById, delete).
First, we generate the todo statics using modelStatics, passing in the database instance and the name of the table that we're planning to use for our todos:
const todoStatics = modelStatics(db, 'todos')Then, if we need to add any other static methods, we attach them to the todoStatics. For example, I think a findByCategory functionality would be nice:
todoStatics.findByCategory = (category) => (
db.manyOrNone(`SELECT * FROM todos WHERE category = $1`, category)
)Finally, we use Object.setPrototypeOf to attach the static methods directoy to the Todo constructor. This means we can call the methods on Todo but not on any instances of Todo.
Object.setPrototypeOf(Todo, todoStatics)Once our statics are set up, we can add any instance-level behavior we need:
Todo.prototype.save = function() {
return db.one(`
INSERT INTO todos
(title, description, category)
VALUES $/title/, $/description/, $/category/
RETURNING *
`, this)
.then(todo => this._modify(todo))
.catch(err => err)
}Routes for new domains should be added to /api/index.js, like so:
apiRouter.use('/todos', require('./todos'))Controllers should have a middleware signature and make use of model methods.
It should be possible to use one controller method in multiple domain routes.
If a route needs user information and also information about todos belonging to a particular user, one should be able to grab the user show controller method and the todos showByUser controller method and use both of them in the same route.
Any time a controller ends up with data that it needs to send back to the client, it should attach that data to res.locals.data and then call next.
send is a little utility function that should be at the end of every route. It simply sends back whatever happens to be in res.locals.data, with the status that is in res.locals.status.
module.exports = (req, res) => {
const { status, data } = res.locals
res.status(status || 200).json(data)
}The client in this app has been bootstrapped with create-react-app and thus has access to everything that CRA gives you -- hot reloading, build scripts, ejection capabilities, etc. A few things to keep in mind:
- Make sure the proxy in
package.jsonmatches the port that the server is running on. - Any
fetchrequest to the backend must havecredentials: 'include'as part of the request settings. Otherwise, the backend will not have access toreq.user.
The passport setup is based on the work of Vincent Abruzzo, who has also been an inspiration and encouragement as I worked on this project. I'd also like to acknowledge Jason Seminara and Ari Brenner for the many, many conversations that led to some of the conventions you see here.

Dragon by Angela Dinh from the Noun Project (a reference, of course, to The Dragonriders of Pern)
