├── .nvmrc ├── .gitignore ├── public └── favicon.ico ├── .env.example ├── prettier.config.js ├── helpers ├── delay.js └── getServerData.js ├── plugins ├── formbody.js ├── static.js ├── templating.js └── session.js ├── postcss.config.js ├── routes ├── logout.js ├── login.js ├── pages.js ├── userCreate.js └── userUpdate.js ├── bundle ├── main.pcss └── main.js ├── views ├── home.twig ├── partials │ ├── layout.twig │ ├── header.twig │ └── alert.twig ├── login.twig ├── create.twig ├── account.twig └── svg │ └── logo.svg ├── tailwind.config.js ├── models └── User.js ├── tests ├── routes │ ├── example.test.js │ └── root.test.js ├── plugins │ └── support.test.js └── helper.js ├── app.js ├── README.md └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.8.0 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | node_modules 4 | public/_compiled 5 | secret-key 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattwaler/fastify-starter/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE='mongodb://localhost:27017/fastify' 2 | SALT_ROUNDS=4 3 | PORT=3000 4 | FASTIFY_ADDRESS=127.0.0.1 5 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "es5", 3 | semi: false, 4 | singleQuote: true, 5 | } 6 | -------------------------------------------------------------------------------- /helpers/delay.js: -------------------------------------------------------------------------------- 1 | module.exports = async function delay(ms) { 2 | return new Promise((resolve) => { 3 | setTimeout(() => resolve(), ms) 4 | }) 5 | } 6 | -------------------------------------------------------------------------------- /plugins/formbody.js: -------------------------------------------------------------------------------- 1 | const fp = require('fastify-plugin') 2 | 3 | module.exports = fp(async function (fastify, opts) { 4 | fastify.register(require('@fastify/formbody')) 5 | }) 6 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('postcss-import'), 4 | require('tailwindcss/nesting'), 5 | require('tailwindcss'), 6 | ], 7 | } 8 | -------------------------------------------------------------------------------- /routes/logout.js: -------------------------------------------------------------------------------- 1 | module.exports = async function (fastify, opts) { 2 | fastify.get('/api/logout', async function (request, reply) { 3 | request.session.delete('session') 4 | return reply.redirect('/') 5 | }) 6 | } 7 | -------------------------------------------------------------------------------- /plugins/static.js: -------------------------------------------------------------------------------- 1 | const fp = require('fastify-plugin') 2 | const path = require('path') 3 | 4 | module.exports = fp(async function (fastify, opts) { 5 | fastify.register(require('@fastify/static'), { 6 | root: path.join(__dirname, '../', 'public'), 7 | }) 8 | }) 9 | -------------------------------------------------------------------------------- /bundle/main.pcss: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | @import 'tailwindcss/components'; 3 | @import 'tailwindcss/utilities'; 4 | 5 | @layer base { 6 | html, body { 7 | @apply w-full overflow-x-hidden antialiased text-gray-900 font-sans; 8 | } 9 | 10 | * { 11 | @apply relative; 12 | } 13 | 14 | [x-cloak] { 15 | @apply hidden; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /plugins/templating.js: -------------------------------------------------------------------------------- 1 | const fp = require('fastify-plugin') 2 | const path = require('path') 3 | 4 | module.exports = fp(async function (fastify, opts) { 5 | fastify.register(require('@fastify/view'), { 6 | engine: { 7 | twig: require('twig') 8 | }, 9 | includeViewExtension: true, 10 | root: path.join(__dirname, '../', 'views'), 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /views/home.twig: -------------------------------------------------------------------------------- 1 | {% extends 'partials/layout.twig' %} 2 | {% set title = 'Home' %} 3 | 4 | {% block body %} 5 |
6 |

7 | Hello, {{ user.name ?? user.email ?? 'guest' }}! 8 |

9 |

Welcome to my fastify starter project! Hope you like it.

10 |
11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | './views/**/*.twig', 4 | ], 5 | theme: { 6 | container: { 7 | center: true, 8 | padding: '2rem', 9 | }, 10 | debugScreens: { 11 | position: ['bottom', 'right'], 12 | }, 13 | }, 14 | plugins: [ 15 | require('@tailwindcss/forms'), 16 | require('tailwindcss-debug-screens'), 17 | ], 18 | 19 | } 20 | -------------------------------------------------------------------------------- /plugins/session.js: -------------------------------------------------------------------------------- 1 | const fp = require('fastify-plugin') 2 | const { readFileSync } = require('fs') 3 | const path = require('path') 4 | 5 | module.exports = fp(async function (fastify, opts) { 6 | fastify.register(require('@fastify/secure-session'), { 7 | cookieName: 'session', 8 | key: readFileSync(path.join(__dirname, '../', 'secret-key')), 9 | cookie: { 10 | path: '/' 11 | } 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /models/User.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | 3 | const UserSchema = new mongoose.Schema({ 4 | email: { 5 | type: String, 6 | required: true, 7 | unique: true, 8 | maxlength: 64, 9 | }, 10 | password: { 11 | type: String, 12 | required: true, 13 | }, 14 | name: { 15 | type: String, 16 | } 17 | }) 18 | 19 | module.exports = mongoose.models.User || mongoose.model('User', UserSchema) 20 | -------------------------------------------------------------------------------- /helpers/getServerData.js: -------------------------------------------------------------------------------- 1 | const User = require('../models/User') 2 | 3 | module.exports = async function getServerData(request) { 4 | const session = request.session.get('session') 5 | let data = { 6 | devMode: process.env.NODE_ENV !== 'production' 7 | } 8 | if (session) { 9 | const user = await User.findById(session.id) 10 | data = { ...data, user } 11 | } 12 | return { 13 | data, 14 | session, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /bundle/main.js: -------------------------------------------------------------------------------- 1 | import 'htmx.org' 2 | import Alpine from 'alpinejs' 3 | import axios from 'axios' 4 | 5 | // Toss these libraries on the window for ease of use. 6 | window.axios = axios 7 | window.Alpine = Alpine 8 | 9 | // Start Alpine when the page is ready. 10 | window.addEventListener('DOMContentLoaded', (event) => { 11 | Alpine.start() 12 | }) 13 | 14 | // Restart Alpine when the DOM is altered by HTMX. 15 | document.body.addEventListener('htmx:afterSwap', () => { 16 | Alpine.start() 17 | }) 18 | -------------------------------------------------------------------------------- /views/partials/layout.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | {{ title }} 10 | {{ block('head') }} 11 | 12 | 13 | {% include 'header.twig' %} 14 | {{ block('body') }} 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/routes/example.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('tap') 4 | const { build } = require('../helper') 5 | 6 | test('example is loaded', async (t) => { 7 | const app = build(t) 8 | 9 | const res = await app.inject({ 10 | url: '/example' 11 | }) 12 | t.equal(res.payload, 'this is an example') 13 | }) 14 | 15 | // inject callback style: 16 | // 17 | // test('example is loaded', (t) => { 18 | // t.plan(2) 19 | // const app = build(t) 20 | // 21 | // app.inject({ 22 | // url: '/example' 23 | // }, (err, res) => { 24 | // t.error(err) 25 | // t.equal(res.payload, 'this is an example') 26 | // }) 27 | // }) 28 | -------------------------------------------------------------------------------- /tests/routes/root.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('tap') 4 | const { build } = require('../helper') 5 | 6 | test('default root route', async (t) => { 7 | const app = build(t) 8 | 9 | const res = await app.inject({ 10 | url: '/' 11 | }) 12 | t.deepEqual(JSON.parse(res.payload), { root: true }) 13 | }) 14 | 15 | // inject callback style: 16 | // 17 | // test('default root route', (t) => { 18 | // t.plan(2) 19 | // const app = build(t) 20 | // 21 | // app.inject({ 22 | // url: '/' 23 | // }, (err, res) => { 24 | // t.error(err) 25 | // t.deepEqual(JSON.parse(res.payload), { root: true }) 26 | // }) 27 | // }) 28 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const path = require('path') 3 | const AutoLoad = require('@fastify/autoload') 4 | const mongoose = require('mongoose') 5 | 6 | module.exports = async function (fastify, opts) { 7 | 8 | // Database connection 9 | mongoose.set('strictQuery', false) 10 | await mongoose.connect(process.env.DATABASE) 11 | 12 | // Load all plugins 13 | fastify.register(AutoLoad, { 14 | dir: path.join(__dirname, 'plugins'), 15 | options: Object.assign({}, opts) 16 | }) 17 | 18 | // Load all routes 19 | fastify.register(AutoLoad, { 20 | dir: path.join(__dirname, 'routes'), 21 | options: Object.assign({}, opts) 22 | }) 23 | 24 | } 25 | -------------------------------------------------------------------------------- /tests/plugins/support.test.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { test } = require('tap') 4 | const Fastify = require('fastify') 5 | const Support = require('../../plugins/support') 6 | 7 | test('support works standalone', async (t) => { 8 | const fastify = Fastify() 9 | fastify.register(Support) 10 | 11 | await fastify.ready() 12 | t.equal(fastify.someSupport(), 'hugs') 13 | }) 14 | 15 | // You can also use plugin with opts in fastify v2 16 | // 17 | // test('support works standalone', (t) => { 18 | // t.plan(2) 19 | // const fastify = Fastify() 20 | // fastify.register(Support) 21 | // 22 | // fastify.ready((err) => { 23 | // t.error(err) 24 | // t.equal(fastify.someSupport(), 'hugs') 25 | // }) 26 | // }) 27 | -------------------------------------------------------------------------------- /routes/login.js: -------------------------------------------------------------------------------- 1 | const User = require('../models/User') 2 | const bcrypt = require('bcrypt') 3 | 4 | module.exports = async function (fastify, opts) { 5 | fastify.post('/api/login', async function (request, reply) { 6 | const { body } = request 7 | try { 8 | const user = await User.findOne({ email: body.email }) 9 | const match = await bcrypt.compare(body.password, user.password) 10 | if (match) { 11 | request.session.set('session', { id: user._id }) 12 | return reply.header('HX-Redirect', '/').send({ success: true }) 13 | } 14 | } catch (err) { 15 | return reply.view('partials/alert', { 16 | success: false, 17 | message: 'Invalid credentials.', 18 | }) 19 | } 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /tests/helper.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // This file contains code that we reuse 4 | // between our tests. 5 | 6 | const Fastify = require('fastify') 7 | const fp = require('fastify-plugin') 8 | const App = require('../app') 9 | 10 | // Fill in this config with all the configurations 11 | // needed for testing the application 12 | function config () { 13 | return {} 14 | } 15 | 16 | // automatically build and tear down our instance 17 | function build (t) { 18 | const app = Fastify() 19 | 20 | // fastify-plugin ensures that all decorators 21 | // are exposed for testing purposes, this is 22 | // different from the production setup 23 | app.register(fp(App), config()) 24 | 25 | // tear down our app after we are done 26 | t.tearDown(app.close.bind(app)) 27 | 28 | return app 29 | } 30 | 31 | module.exports = { 32 | config, 33 | build 34 | } 35 | -------------------------------------------------------------------------------- /routes/pages.js: -------------------------------------------------------------------------------- 1 | const getServerData = require('../helpers/getServerData') 2 | 3 | module.exports = async function (fastify, opts) { 4 | fastify.get('/', async function (request, reply) { 5 | const { data } = await getServerData(request) 6 | return reply.view('home', data) 7 | }) 8 | 9 | fastify.get('/account', async function (request, reply) { 10 | const { data } = await getServerData(request) 11 | return reply.view('account', data) 12 | }) 13 | 14 | fastify.get('/create', async function (request, reply) { 15 | const { session, data } = await getServerData(request) 16 | return session ? reply.redirect('/') : reply.view('create', data) 17 | }) 18 | 19 | fastify.get('/login', async function (request, reply) { 20 | const { session, data } = await getServerData(request) 21 | return session ? reply.redirect('/') : reply.view('login', data) 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /views/login.twig: -------------------------------------------------------------------------------- 1 | {% extends 'partials/layout.twig' %} 2 | {% set title = 'Login' %} 3 | 4 | {% block body %} 5 |
6 |

7 | {{ title }} 8 |

9 |
10 | 18 | 26 | 29 |
30 |
31 |
32 | {% endblock %} 33 | -------------------------------------------------------------------------------- /views/create.twig: -------------------------------------------------------------------------------- 1 | {% extends 'partials/layout.twig' %} 2 | {% set title = 'Create' %} 3 | 4 | {% block body %} 5 |
6 |

7 | {{ title }} 8 |

9 |
10 | 19 | 28 | 31 |
32 |
33 |
34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /views/partials/header.twig: -------------------------------------------------------------------------------- 1 | {% set buttonClasses = 'inline-block py-2 px-4 border border-transparent rounded-md text-sm font-medium hover:bg-opacity-75' %} 2 | 3 |
4 | 21 |
22 | -------------------------------------------------------------------------------- /routes/userCreate.js: -------------------------------------------------------------------------------- 1 | const User = require('../models/User') 2 | const bcrypt = require('bcrypt') 3 | const validator = require('validator') 4 | 5 | module.exports = async function (fastify, opts) { 6 | fastify.post('/api/user', async function (request, reply) { 7 | const { body } = request 8 | 9 | // Validate Email 10 | if (!validator.isEmail(body.email)) { 11 | return reply.view('partials/alert', { 12 | success: false, 13 | message: 'Please use a valid email address.', 14 | }) 15 | } 16 | 17 | // Hash Password 18 | const hashedPassword = await bcrypt.hash( 19 | body.password, 20 | parseInt(process.env.SALT_ROUNDS) 21 | ) 22 | 23 | // Attempt to Create New User 24 | try { 25 | await User.create({ ...body, password: hashedPassword }) 26 | return reply.header('HX-Redirect', '/login').send({ success: true }) 27 | } catch (err) { 28 | return reply.view('partials/alert', { 29 | success: false, 30 | message: 'Something went wrong.', 31 | }) 32 | } 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🏎💨 Fastify Starter 2 | 3 | This project aims to create the most pragmatic fullstack JS project by combining the best tools in the ecosystem. 4 | 5 | ## ⭐️ Requirements 6 | 7 | - [MongoDB](https://www.mongodb.com/) 8 | - [Node](https://nodejs.org/en/) 9 | - [NVM](https://github.com/nvm-sh/nvm) 10 | 11 | ## 🧰 Tools 12 | 13 | This template uses the following tools: 14 | 15 | - [Alpine.js](https://alpinejs.dev/) 16 | - [Fastify](https://www.fastify.io/) 17 | - [HTMX](https://htmx.org/) 18 | - [MongoDB](https://www.mongodb.com/) 19 | - [Mongoose](https://mongoosejs.com/) 20 | - [TailwindCSS](https://tailwindcss.com/) 21 | - [Twig](https://github.com/twigjs/twig.js/wiki/Implementation-Notes) 22 | 23 | ## 🛠 Getting Started 24 | 25 | 1. Ensure MongoDB is running locally 26 | 2. Duplicate `.env.example` contents into a `.env` file 27 | 3. Run `nvm use` to switch to the correct Node version 28 | 4. Run `npm i` for that big ol' `node_modules` folder 29 | 5. Run `npm run key` to generate a session key 30 | 6. Run `npm run dev` and start developing 31 | 32 | ### 👋🏻 Thank you! 33 | 34 | I appreciate you taking time to check out this project. Please leave a star and share it if you found it useful! 35 | -------------------------------------------------------------------------------- /routes/userUpdate.js: -------------------------------------------------------------------------------- 1 | const User = require('../models/User') 2 | const validator = require('validator') 3 | const delay = require('../helpers/delay') 4 | 5 | module.exports = async function (fastify, opts) { 6 | fastify.patch('/api/user', async function (request, reply) { 7 | const session = request.session.get('session') 8 | 9 | await delay(1000) 10 | 11 | // Authenticate 12 | if (!session) { 13 | return reply.view('partials/alert', { 14 | success: false, 15 | message: 'Not authenticated.', 16 | }) 17 | } 18 | 19 | // Validate Email 20 | if (!validator.isEmail(request.body.email)) { 21 | return reply.view('partials/alert', { 22 | success: false, 23 | message: 'Invalid email address.', 24 | }) 25 | } 26 | 27 | // Update 28 | try { 29 | await User.findOneAndUpdate(session.id, request.body, { new: true }) 30 | return reply.view('partials/alert', { 31 | success: true, 32 | message: 'Your account details have been updated successfully.', 33 | }) 34 | } catch (err) { 35 | console.log(err) 36 | return reply.view('partials/alert', { 37 | success: false, 38 | message: 'There was an error updating your account. Please try again.', 39 | }) 40 | } 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /views/partials/alert.twig: -------------------------------------------------------------------------------- 1 |
11 |
12 | 13 | {% if success %} 14 | 15 | {% else %} 16 | 17 | {% endif %} 18 | 19 |

20 | {{ message }} 21 |

22 |
23 |
24 | -------------------------------------------------------------------------------- /views/account.twig: -------------------------------------------------------------------------------- 1 | {% extends 'partials/layout.twig' %} 2 | {% set title = 'Account' %} 3 | 4 | {% block body %} 5 |
6 |

7 | {{ title }} 8 |

9 |
15 | 24 | 33 |
34 | 40 | 41 | 42 | 43 | 44 |
45 |
46 |
47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fastify-starter", 3 | "version": "1.0.0", 4 | "description": "A starter for Fastify with Twig, Alpine, and Tailwind.", 5 | "main": "app.js", 6 | "config": { 7 | "css": "tailwindcss -i bundle/main.pcss -o public/_compiled/main.bundle.css --postcss", 8 | "js": "esbuild bundle/main.js --outfile=public/_compiled/main.bundle.js --bundle" 9 | }, 10 | "scripts": { 11 | "build": "NODE_ENV=production run-p build:*", 12 | "build:css": "$npm_package_config_css --minify", 13 | "build:js": "$npm_package_config_js", 14 | "dev": "NODE_ENV=development npm-run-all -p dev:*", 15 | "dev:fastify": "fastify start -w -l info -P app.js", 16 | "dev:css": "$npm_package_config_css --watch", 17 | "dev:js": "$npm_package_config_js --watch", 18 | "key": "npx @fastify/secure-session > secret-key", 19 | "start": "NODE_ENV=production fastify start -l info app.js", 20 | "test": "tap tests/**/*.test.js" 21 | }, 22 | "keywords": [], 23 | "author": "Matt Waler", 24 | "license": "ISC", 25 | "dependencies": { 26 | "@fastify/autoload": "^5.1.0", 27 | "@fastify/formbody": "^7.0.1", 28 | "@fastify/secure-session": "^5.2.0", 29 | "@fastify/static": "^6.4.0", 30 | "@fastify/view": "^7.0.0", 31 | "alpinejs": "^3.0.6", 32 | "axios": "^0.27.2", 33 | "bcrypt": "^5.0.1", 34 | "dotenv": "^16.0.1", 35 | "fastify": "^4.2.1", 36 | "fastify-cli": "^4.3.0", 37 | "fastify-plugin": "^4.0.0", 38 | "htmx.org": "^1.4.1", 39 | "mongoose": "^6.1.4", 40 | "twig": "^1.15.4", 41 | "validator": "^13.6.0" 42 | }, 43 | "devDependencies": { 44 | "@tailwindcss/forms": "^0.5.2", 45 | "esbuild": "^0.14.9", 46 | "npm-run-all": "^4.1.5", 47 | "postcss": "^8.4.5", 48 | "postcss-import": "^14.0.2", 49 | "tailwindcss": "^3.0.8", 50 | "tailwindcss-debug-screens": "^2.0.0", 51 | "tap": "^16.3.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /views/svg/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | --------------------------------------------------------------------------------