├── .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 |
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 |
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 |
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 |
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 |
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 |
4 |
--------------------------------------------------------------------------------