├── .development.env
├── .dockerignore
├── .eslintrc.js
├── .github
└── workflows
│ ├── docker.yml
│ └── test.yml
├── .gitignore
├── Dockerfile
├── README.md
├── babel.config.js
├── client
├── app.jsx
├── assets
│ ├── favicon.ico
│ └── robots.txt
├── components
│ ├── Footer
│ │ ├── Footer.jsx
│ │ └── Footer.test.js
│ ├── Header
│ │ ├── Header.jsx
│ │ └── Header.test.js
│ └── Loader
│ │ ├── Loader.css
│ │ ├── Loader.jsx
│ │ └── Loader.test.js
├── environment
│ ├── .env
│ └── README.md
├── global.css
├── index.html
├── index.jsx
└── views
│ ├── About.jsx
│ ├── About.test.js
│ ├── Home.jsx
│ └── Home.test.js
├── jest.config.js
├── package-lock.json
├── package.json
├── postcss.config.js
├── server
├── __tests__
│ └── demo.js
├── app.js
└── index.js
├── tailwind.config.js
└── vite.config.js
/.development.env:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/harrisoncramer/fullstack-template/788c0178519614de19a9088624a66bed1ad722a6/.development.env
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .git
3 | .development.env
4 | Dockerfile
5 | README.md
6 | .gitignore
7 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2021: true,
5 | node: true,
6 | jest: true,
7 | },
8 | extends: ['eslint:recommended', 'plugin:react/recommended'],
9 | parserOptions: {
10 | ecmaFeatures: {
11 | jsx: true,
12 | },
13 | ecmaVersion: 12,
14 | sourceType: 'module',
15 | },
16 | plugins: ['react'],
17 | rules: {},
18 | };
19 |
--------------------------------------------------------------------------------
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------
1 | name: Build and publish docker image
2 | on:
3 | pull_request:
4 | branches:
5 | - main
6 | jobs:
7 | push_to_registry:
8 | name: Push Docker image to Docker Hub
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Check out the repo
12 | uses: actions/checkout@v2
13 | - name: Log in to Docker Hub
14 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
15 | with:
16 | username: ${{ secrets.DOCKER_USERNAME }}
17 | password: ${{ secrets.DOCKER_PASSWORD }}
18 | - name: Extract metadata (tags, labels) for Docker
19 | id: meta
20 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
21 | with:
22 | images: kingofcramers/fullstack-template
23 | - name: Build and push Docker image
24 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
25 | with:
26 | context: .
27 | push: true
28 | tags: ${{ steps.meta.outputs.tags }}
29 | labels: ${{ steps.meta.outputs.labels }}
30 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Node.js CI
2 | on:
3 | pull_request:
4 | branches: ['main']
5 | push:
6 | branches: ['dev', 'main']
7 | jobs:
8 | test:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v2
12 | - name: Setup NodeJS
13 | uses: actions/setup-node@v2
14 | with:
15 | node-version: ${{ matrix.node-version }}
16 | cache: npm
17 | - run: npm ci
18 | - run: npm run test
19 | push_to_registry:
20 | name: Push Docker image to Docker Hub
21 | needs: test
22 | if: github.ref == 'refs/heads/main'
23 | runs-on: ubuntu-latest
24 | steps:
25 | - name: Check out the repo
26 | uses: actions/checkout@v2
27 | - name: Log in to Docker Hub
28 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
29 | with:
30 | username: ${{ secrets.DOCKER_USERNAME }}
31 | password: ${{ secrets.DOCKER_PASSWORD }}
32 | - name: Extract metadata (tags, labels) for Docker
33 | id: meta
34 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38
35 | with:
36 | images: kingofcramers/fullstack-template
37 | - name: Build and push Docker image
38 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc
39 | with:
40 | context: .
41 | push: true
42 | tags: ${{ steps.meta.outputs.tags }}
43 | labels: ${{ steps.meta.outputs.labels }}
44 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | build
3 | .DS_Store
4 | .production.env
5 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | ## Build layer
2 | FROM node:16.0.0 AS builder
3 | WORKDIR /app
4 | COPY package*.json .
5 | RUN npm install
6 | COPY . .
7 | RUN npm run build
8 |
9 | ## Production layer
10 | FROM alpine
11 | WORKDIR /app
12 | COPY --from=builder /app/package*.json .
13 | COPY --from=builder /app/build build
14 | COPY --from=builder /app/server server
15 | RUN apk add --update nodejs npm
16 | RUN npm install --only=prod
17 | RUN addgroup -S app && adduser -S prod -G app
18 | USER prod
19 | CMD ["npm", "run", "serve"]
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Template Repository
2 |
3 | This is a template repository for a full-stack, dockerized application using React, Vite, Express, and Tailwind. It includes:
4 |
5 | - Vite for fast reloads
6 | - Tailwind for utility-first styling
7 | - Express for SPA backend
8 | - React-router for routes
9 | - Lazy-loading for optimization
10 | - Supertest for backend tests
11 | - React-Testing-Library for frontend tests
12 | - Github Actions for CI\*\*
13 | - Docker for containerization
14 |
15 | ## Installation
16 |
17 | `npm install`
18 |
19 | ## Development
20 |
21 | Full Dev: `npm run start`
22 |
23 | Backend only: `npm run start:express`
24 |
25 | Frontend only: `npm run start:vite`
26 |
27 | ## Tests
28 |
29 | `npm run test`
30 |
31 | ## Docker
32 |
33 | Build the image: `docker build -t yourusername/yourapp . `
34 |
35 | Run the image (map the ports): `docker run -dit -p 3000:3000 yourusername/yourapp`
36 |
37 | ## Github Actions
38 |
39 | The github actions in this repository are configured to build and push a Docker image to my account by default. You can either delete the `.github` folder or configure them with your own account and credentials. You may also delete the docker job, leaving the other CI tests in place.
40 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: ["babel-plugin-transform-vite-meta-env"],
3 | presets: [
4 | [
5 | "@babel/preset-env",
6 | {
7 | targets: {
8 | node: "current",
9 | },
10 | },
11 | ],
12 | "@babel/preset-react",
13 | ],
14 | };
15 |
--------------------------------------------------------------------------------
/client/app.jsx:
--------------------------------------------------------------------------------
1 | import React, { Suspense, lazy } from "react";
2 | import { BrowserRouter as Router, Route, Switch } from "react-router-dom";
3 | import Header from "./components/Header/Header";
4 | import Footer from "./components/Footer/Footer";
5 | import Loader from "./components/Loader/Loader";
6 |
7 | const Home = lazy(() => import("./views/Home"));
8 | const About = lazy(() => import("./views/About"));
9 |
10 | import "./global.css";
11 |
12 | const App = () => {
13 | return (
14 |
15 |
16 | }>
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | export default App;
32 |
--------------------------------------------------------------------------------
/client/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/harrisoncramer/fullstack-template/788c0178519614de19a9088624a66bed1ad722a6/client/assets/favicon.ico
--------------------------------------------------------------------------------
/client/assets/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | allow: /
3 |
--------------------------------------------------------------------------------
/client/components/Footer/Footer.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const Footer = () => {
4 | return (
5 | <>
6 |
This is the footer
7 | >
8 | );
9 | };
10 |
11 | export default Footer;
12 |
--------------------------------------------------------------------------------
/client/components/Footer/Footer.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import '@testing-library/jest-dom';
6 | import React from 'react';
7 | import { screen, render } from '@testing-library/react';
8 | import Footer from './Footer';
9 |
10 | test('Renders footer', () => {
11 | render();
12 | const footer = screen.getByText(/This is the footer/i);
13 | expect(footer).toBeInTheDocument();
14 | });
15 |
--------------------------------------------------------------------------------
/client/components/Header/Header.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const Header = () => {
4 | return (
5 | <>
6 | This is the header
7 | >
8 | );
9 | };
10 |
11 | export default Header;
12 |
--------------------------------------------------------------------------------
/client/components/Header/Header.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import '@testing-library/jest-dom';
6 | import React from 'react';
7 | import { screen, render } from '@testing-library/react';
8 | import Header from './Header';
9 |
10 | test('Renders header', () => {
11 | render();
12 | const header = screen.getByText(/This is the header/i);
13 | expect(header).toBeInTheDocument();
14 | });
15 |
--------------------------------------------------------------------------------
/client/components/Loader/Loader.css:
--------------------------------------------------------------------------------
1 | @use "../../variables";
2 |
3 | .loading-container {
4 | display: flex;
5 | width: 100%;
6 | justify-content: center;
7 | align-items: center;
8 | }
9 |
10 | .lds-ellipsis {
11 | display: inline-block;
12 | position: relative;
13 | width: 80px;
14 | height: 80px;
15 | }
16 | .lds-ellipsis div {
17 | position: absolute;
18 | top: 33px;
19 | width: 13px;
20 | height: 13px;
21 | border-radius: 50%;
22 | background: variables.$theme-color-main;
23 | animation-timing-function: cubic-bezier(0, 1, 1, 0);
24 | }
25 | .lds-ellipsis div:nth-child(1) {
26 | left: 8px;
27 | animation: lds-ellipsis1 0.6s infinite;
28 | }
29 | .lds-ellipsis div:nth-child(2) {
30 | left: 8px;
31 | animation: lds-ellipsis2 0.6s infinite;
32 | }
33 | .lds-ellipsis div:nth-child(3) {
34 | left: 32px;
35 | animation: lds-ellipsis2 0.6s infinite;
36 | }
37 | .lds-ellipsis div:nth-child(4) {
38 | left: 56px;
39 | animation: lds-ellipsis3 0.6s infinite;
40 | }
41 | @keyframes lds-ellipsis1 {
42 | 0% {
43 | transform: scale(0);
44 | }
45 | 100% {
46 | transform: scale(1);
47 | }
48 | }
49 | @keyframes lds-ellipsis3 {
50 | 0% {
51 | transform: scale(1);
52 | }
53 | 100% {
54 | transform: scale(0);
55 | }
56 | }
57 | @keyframes lds-ellipsis2 {
58 | 0% {
59 | transform: translate(0, 0);
60 | }
61 | 100% {
62 | transform: translate(24px, 0);
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/client/components/Loader/Loader.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import "./Loader.css";
3 |
4 | const Loader = () => {
5 | return (
6 |
14 | );
15 | };
16 |
17 | export default Loader;
18 |
--------------------------------------------------------------------------------
/client/components/Loader/Loader.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import '@testing-library/jest-dom';
6 | import React from 'react';
7 | import { screen, render } from '@testing-library/react';
8 | import Loader from './Loader';
9 |
10 | test('Renders loader', () => {
11 | render();
12 | const loader = screen.getByTestId('loading-container');
13 | expect(loader).toBeInTheDocument();
14 | });
15 |
--------------------------------------------------------------------------------
/client/environment/.env:
--------------------------------------------------------------------------------
1 | VITE_CLIENT_API_KEY='this_is_some_key_that_is_not_secret'
2 |
--------------------------------------------------------------------------------
/client/environment/README.md:
--------------------------------------------------------------------------------
1 | # Environment
2 |
3 | Your client-side, non-secret environment files go in here.
4 |
--------------------------------------------------------------------------------
/client/global.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 | @tailwind variants;
5 |
--------------------------------------------------------------------------------
/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Template
8 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/client/index.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { BrowserRouter } from "react-router-dom";
3 | import { render } from "react-dom";
4 | import App from "./app.jsx";
5 |
6 | render(
7 |
8 |
9 | ,
10 | document.getElementById("root")
11 | );
12 |
--------------------------------------------------------------------------------
/client/views/About.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const About = () => {
4 | return (
5 | <>
6 | About page
7 | >
8 | );
9 | };
10 |
11 | export default About;
12 |
--------------------------------------------------------------------------------
/client/views/About.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import '@testing-library/jest-dom';
6 | import React from 'react';
7 | import { screen, render } from '@testing-library/react';
8 | import About from './About';
9 |
10 | test('Renders about view', () => {
11 | render();
12 | const about = screen.getByText(/About page/i);
13 | expect(about).toBeInTheDocument();
14 | });
15 |
--------------------------------------------------------------------------------
/client/views/Home.jsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const Home = () => {
4 | const handleApiCall = async (e) => {
5 | e.preventDefault();
6 | try {
7 | const response = await fetch("/api/v1");
8 | const data = await response.json();
9 | console.log(data);
10 | } catch (err) {
11 | console.error("Error, could not proxy to API", err);
12 | }
13 | };
14 |
15 | return (
16 | <>
17 | Home page
18 |
19 | >
20 | );
21 | };
22 |
23 | export default Home;
24 |
--------------------------------------------------------------------------------
/client/views/Home.test.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @jest-environment jsdom
3 | */
4 |
5 | import '@testing-library/jest-dom';
6 | import React from 'react';
7 | import { screen, render } from '@testing-library/react';
8 | import Home from './Home';
9 |
10 | test('Renders home view', () => {
11 | render();
12 | const home = screen.getByText(/Home page/i);
13 | expect(home).toBeInTheDocument();
14 | });
15 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | const config = {
2 | verbose: true,
3 | moduleNameMapper: { '\\.(css|scss|sass)$': 'identity-obj-proxy' },
4 | };
5 |
6 | module.exports = config;
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "template",
3 | "version": "1.1.0",
4 | "description": "",
5 | "main": "server/app.js",
6 | "scripts": {
7 | "start": "concurrently \"npm run start:vite\" \"npm run start:express\"",
8 | "start:vite": "vite",
9 | "start:express": "PORT=3001 nodemon server/index.js",
10 | "build": "vite build",
11 | "serve": "NODE_ENV=production PORT=3000 node server/index.js",
12 | "test": "jest --detectOpenHandles"
13 | },
14 | "keywords": [],
15 | "author": "",
16 | "license": "ISC",
17 | "dependencies": {
18 | "express": "^4.17.1"
19 | },
20 | "devDependencies": {
21 | "@babel/core": "^7.15.5",
22 | "@babel/preset-env": "^7.15.6",
23 | "@babel/preset-react": "^7.14.5",
24 | "@testing-library/jest-dom": "^5.14.1",
25 | "@testing-library/react": "^12.1.0",
26 | "@vitejs/plugin-react": "^1.1.4",
27 | "autoprefixer": "^10.4.2",
28 | "babel-plugin-transform-vite-meta-env": "^1.0.3",
29 | "concurrently": "^7.0.0",
30 | "eslint": "^7.32.0",
31 | "eslint-plugin-react": "^7.25.1",
32 | "graphql": "^15.5.3",
33 | "identity-obj-proxy": "^3.0.0",
34 | "jest": "^27.2.0",
35 | "nodemon": "^2.0.12",
36 | "postcss": "^8.4.5",
37 | "react": "^17.0.2",
38 | "react-dom": "^17.0.2",
39 | "react-router-dom": "^5.3.0",
40 | "sass": "^1.39.0",
41 | "supertest": "^6.1.6",
42 | "tailwindcss": "^3.0.15",
43 | "vite": "^2.7.12"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/server/__tests__/demo.js:
--------------------------------------------------------------------------------
1 | const request = require('supertest');
2 | const express = require('express');
3 |
4 | const app = require('../app');
5 |
6 | describe('Should test the dummy route', () => {
7 | it('Should recieve the response string', async () => {
8 | const response = await request(app).get('/api/v1');
9 | expect(response.body).toMatchObject({ hello: 'world' });
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/server/app.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const path = require('path');
3 |
4 | const app = express();
5 | app.use(express.json());
6 |
7 | app.get('/api/v1', (req, res) => {
8 | res.status(200).send({ hello: 'world' });
9 | });
10 |
11 | /* Only serve static content in production. Use WDS in development. */
12 | if (process.env.NODE_ENV === 'production') {
13 | app.use('/', express.static(path.resolve(__dirname, '..', 'build')));
14 | app.get('/*', (req, res) => {
15 | res.sendFile(path.resolve(__dirname, '..', 'build', 'index.html'));
16 | });
17 | }
18 |
19 | module.exports = app;
20 |
--------------------------------------------------------------------------------
/server/index.js:
--------------------------------------------------------------------------------
1 | const app = require("./app");
2 |
3 | app.listen(process.env.PORT, () => {
4 | console.log(`API listening on port ${process.env.PORT}.`);
5 | });
6 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | content: ["./client/index.html", "./client/**/*.{vue,js,ts,jsx,tsx}"],
3 | theme: {
4 | extend: {},
5 | },
6 | plugins: [],
7 | };
8 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import react from "@vitejs/plugin-react";
2 | import { defineConfig } from "vite";
3 |
4 | export default defineConfig({
5 | plugins: [react()],
6 | root: "client",
7 | envDir: "./environment",
8 | build: {
9 | outDir: "../build",
10 | },
11 | server: {
12 | proxy: {
13 | "/api": {
14 | target: "http://localhost:3001",
15 | changeOrigin: true,
16 | secure: false,
17 | },
18 | },
19 | },
20 | });
21 |
--------------------------------------------------------------------------------