├── .editorconfig ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── prettier.config.js ├── src ├── client │ ├── createView.tsx │ └── index.ts ├── common │ ├── App.tsx │ ├── actions.ts │ ├── components │ │ ├── CenterInfo.tsx │ │ └── index.ts │ ├── index.ts │ ├── pages │ │ ├── HomePage.tsx │ │ ├── NotFoundPage.tsx │ │ ├── TickerPage.tsx │ │ └── TodoPage.tsx │ ├── state.ts │ └── types │ │ ├── index.ts │ │ ├── state.ts │ │ └── todo.ts └── server │ ├── createView.tsx │ ├── index.ts │ ├── renderIndex.ts │ └── retrieveAsset.ts ├── tsconfig.client.json ├── tsconfig.json ├── tsconfig.server.json └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | max_line_length = null 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY . . 6 | 7 | ENV NODE_ENV production 8 | ENV PORT 9000 9 | 10 | EXPOSE 9000 11 | CMD [ "npm", "run", "serve" ] 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Florian Rappl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # typescript-fullstack-sample 2 | 3 | A sample project to demonstrate creating a full stack web app using TypeScript with Node.js and React. 4 | 5 | ## Prerequisites 6 | 7 | You'll need a working terminal and Node.js installed. From there on after cloning just run `npm i` to install all dependenies in the project's folder. 8 | 9 | Building is done via `npm run build`. After that you can just run `npm start` to get going. A dev server should be up and running at http://localhost:3000. 10 | 11 | ## What's covered? 12 | 13 | - Client-side and Server-side rendering 14 | - Global state container 15 | - Hydration 16 | - Building 17 | 18 | ## What's covered somewhere else? 19 | 20 | I've recreated this whole sample also in Azure DevOps including CI/CD and a multi-project setup. 21 | 22 | You'll find the link to the sample project below: 23 | 24 | :rocket: https://florianrappl.visualstudio.com/typescript-fullstack-sample 25 | 26 | ## What's not covered? 27 | 28 | For the purpose of keeping the sample as minimal / down to the point as possible the following topics have been excluded: 29 | 30 | - Watching / live development 31 | - Advanced debugging 32 | - Styling 33 | - Anything with Redux 34 | 35 | Getting these to run is actually not much of a problem, however, would significantly increase (reading) complexity. 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-fullstack-sample", 3 | "version": "1.0.0", 4 | "description": "A sample project to demonstrate creating a full stack web app using TypeScript with Node.js and React.", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node dist/server/index.js", 8 | "build": "npm run build:server && npm run build:client", 9 | "build:client": "webpack -c webpack.config.js --mode production", 10 | "build:server": "tsc --project tsconfig.server.json", 11 | "serve": "pm2-runtime dist/server/index.js", 12 | "test": "echo \"Error: no test specified\" && exit 1" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/FlorianRappl/typescript-fullstack-sample.git" 17 | }, 18 | "keywords": [ 19 | "typescript", 20 | "fullstack", 21 | "sample", 22 | "react", 23 | "express", 24 | "node" 25 | ], 26 | "author": "Florian Rappl", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/FlorianRappl/typescript-fullstack-sample/issues" 30 | }, 31 | "homepage": "https://github.com/FlorianRappl/typescript-fullstack-sample#readme", 32 | "dependencies": { 33 | "express": "^4.17.1", 34 | "react": "^16.12.0", 35 | "react-dom": "^16.12.0", 36 | "react-hooks-global-state": "^0.16.0", 37 | "react-router": "^5.1.2", 38 | "react-router-dom": "^5.1.2", 39 | "pm2": "^5.2.0" 40 | }, 41 | "devDependencies": { 42 | "@types/express": "^4.17.2", 43 | "@types/node": "^12.12.20", 44 | "@types/react": "^16.9.16", 45 | "@types/react-dom": "^16.9.4", 46 | "@types/react-router": "^5.1.3", 47 | "@types/react-router-dom": "^5.1.3", 48 | "ts-loader": "^6.2.1", 49 | "typescript": "^3.7.3", 50 | "webpack": "^4.41.3", 51 | "webpack-cli": "^3.3.12" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | singleQuote: true, 4 | trailingComma: 'all', 5 | bracketSpacing: true, 6 | parser: 'typescript', 7 | semi: true, 8 | jsxBracketSameLine: true, 9 | }; 10 | -------------------------------------------------------------------------------- /src/client/createView.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { BrowserRouter } from 'react-router-dom'; 3 | import { App, GlobalStateProvider } from '../common'; 4 | 5 | export function createView() { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/client/index.ts: -------------------------------------------------------------------------------- 1 | import { hydrate } from 'react-dom'; 2 | import { createView } from './createView'; 3 | 4 | hydrate(createView(), document.querySelector('#app')); 5 | -------------------------------------------------------------------------------- /src/common/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { Switch, Route } from 'react-router'; 4 | import { HomePage } from './pages/HomePage'; 5 | import { TickerPage } from './pages/TickerPage'; 6 | import { NotFoundPage } from './pages/NotFoundPage'; 7 | import { TodoPage } from './pages/TodoPage'; 8 | 9 | export interface AppProps {} 10 | 11 | export const App: React.FC = () => ( 12 |
13 |

Example application

14 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 | ); 33 | -------------------------------------------------------------------------------- /src/common/actions.ts: -------------------------------------------------------------------------------- 1 | import { Todo, GlobalState } from './types'; 2 | 3 | export function addTodo(state: GlobalState, todo: Todo): GlobalState { 4 | return { 5 | ...state, 6 | todos: [...state.todos, todo], 7 | }; 8 | } 9 | 10 | export function removeTodo(state: GlobalState, todo: Todo): GlobalState { 11 | return { 12 | ...state, 13 | todos: state.todos.filter(m => m.id !== todo.id), 14 | }; 15 | } 16 | 17 | export function updateTodo(state: GlobalState, todo: Todo): GlobalState { 18 | return { 19 | ...state, 20 | todos: state.todos.map(m => (m.id === todo.id ? todo : m)), 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/common/components/CenterInfo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export interface CenterInfoProps { 4 | info: React.ReactNode; 5 | } 6 | 7 | export const CenterInfo: React.FC = ({ info }) => ( 8 |

9 | {info} 10 |

11 | ); 12 | -------------------------------------------------------------------------------- /src/common/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CenterInfo'; 2 | -------------------------------------------------------------------------------- /src/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './App'; 2 | export * from './state'; 3 | -------------------------------------------------------------------------------- /src/common/pages/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { RouteComponentProps } from 'react-router'; 3 | 4 | export const HomePage: React.FC = () => ( 5 | <> 6 |

Homepage

7 |

Some text

8 | 9 | ); 10 | -------------------------------------------------------------------------------- /src/common/pages/NotFoundPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { RouteComponentProps } from 'react-router'; 4 | 5 | export const NotFoundPage: React.FC = () => ( 6 | <> 7 |

Not Found

8 |

The given page has not been found. Sure you did not mistype?

9 |

Back to homepage

10 | 11 | ); 12 | -------------------------------------------------------------------------------- /src/common/pages/TickerPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { RouteComponentProps } from 'react-router'; 3 | import { CenterInfo } from '../components'; 4 | 5 | export const TickerPage: React.FC = () => { 6 | const [value, setValue] = React.useState(0); 7 | 8 | React.useEffect(() => { 9 | const tid = setTimeout(() => setValue(value + 1), 1000); 10 | return () => clearTimeout(tid); 11 | }, [value]); 12 | 13 | return ( 14 | <> 15 |

Ticker

16 |

The following ticker will increment every second:

17 | 18 |

Note: We use a local state, so page changes won't preserve the value.

19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/common/pages/TodoPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { RouteComponentProps } from 'react-router'; 3 | import { useGlobalState, actions } from '../state'; 4 | 5 | export const AddTodo: React.FC = () => { 6 | const [message, setMessage] = React.useState(''); 7 | return ( 8 |
{ 10 | actions.addTodo({ id: Math.random().toString(36), done: false, message }); 11 | setMessage(''); 12 | e.preventDefault(); 13 | }}> 14 | setMessage(e.target.value)} /> 15 | 16 |
17 | ); 18 | }; 19 | 20 | const AvailableTodos: React.FC = () => { 21 | const [todos] = useGlobalState('todos'); 22 | return ( 23 |
    24 | {todos.map(todo => ( 25 |
  • 26 | {todo.message} 27 |
  • 28 | ))} 29 |
30 | ); 31 | }; 32 | 33 | export const TodoPage: React.FC = () => ( 34 | <> 35 |

ToDos

36 | 37 | 38 | 39 | ); 40 | -------------------------------------------------------------------------------- /src/common/state.ts: -------------------------------------------------------------------------------- 1 | import * as rawActions from './actions'; 2 | import { createStore } from 'react-hooks-global-state'; 3 | import { GlobalState } from './types'; 4 | 5 | type RawActions = typeof rawActions; 6 | type ActionNames = keyof RawActions; 7 | type RemainingArgs = T extends (_: any, ...args: infer U) => any ? U : never; 8 | type Actions = { 9 | [P in ActionNames]: { 10 | type: P; 11 | payload: RemainingArgs; 12 | }; 13 | }[ActionNames]; 14 | 15 | function reducer(state: GlobalState, action: Actions) { 16 | const fn = rawActions[action.type]; 17 | return fn(state, ...action.payload); 18 | } 19 | 20 | const initialState: GlobalState = { todos: [] }; 21 | 22 | export const { GlobalStateProvider, useGlobalState, dispatch } = createStore(reducer, initialState); 23 | 24 | type NewActions = { 25 | [P in ActionNames]: (...args: RemainingArgs) => void; 26 | }; 27 | 28 | export const actions = Object.keys(rawActions).reduce((obj, type: ActionNames) => { 29 | obj[type] = (...payload) => 30 | dispatch({ 31 | type, 32 | payload, 33 | }); 34 | return obj; 35 | }, {} as NewActions); 36 | -------------------------------------------------------------------------------- /src/common/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './state'; 2 | export * from './todo'; 3 | -------------------------------------------------------------------------------- /src/common/types/state.ts: -------------------------------------------------------------------------------- 1 | import { Todo } from './todo'; 2 | 3 | export interface GlobalState { 4 | todos: Array; 5 | } 6 | -------------------------------------------------------------------------------- /src/common/types/todo.ts: -------------------------------------------------------------------------------- 1 | export interface Todo { 2 | id: string; 3 | done: boolean; 4 | message: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/server/createView.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { StaticRouter } from 'react-router'; 3 | import { App, GlobalStateProvider } from '../common'; 4 | 5 | export function createView(path: string) { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import { renderIndex } from './renderIndex'; 3 | import { retrieveAsset } from './retrieveAsset'; 4 | 5 | const app = express(); 6 | const port = process.env.PORT || 3000; 7 | 8 | // happy path 9 | app.get('/', renderIndex); 10 | 11 | // define more routes here (e.g., for POSTing data) 12 | 13 | // resolve any static content, such as /app.js 14 | app.get('*', retrieveAsset); 15 | 16 | // SPA fallback 17 | app.get('*', renderIndex); 18 | 19 | app.listen(port, () => { 20 | console.log(`Server running at [ http://localhost:${port} ] ...`); 21 | }); 22 | -------------------------------------------------------------------------------- /src/server/renderIndex.ts: -------------------------------------------------------------------------------- 1 | import { RequestHandler } from 'express'; 2 | import { renderToString } from 'react-dom/server'; 3 | import { createView } from './createView'; 4 | 5 | export const renderIndex: RequestHandler = (req, res) => { 6 | const view = createView(req.path); 7 | const content = renderToString(view); 8 | res.send(` 9 | 10 | 11 | 12 | 13 | 14 | Sample Document 15 | 16 | 17 |
${content}
18 | 19 | 20 | 21 | `); 22 | }; 23 | -------------------------------------------------------------------------------- /src/server/retrieveAsset.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import { resolve } from 'path'; 3 | 4 | const clientDir = resolve(__dirname, '..', 'client'); 5 | 6 | export const retrieveAsset = express.static(clientDir, { 7 | fallthrough: true, 8 | }); 9 | -------------------------------------------------------------------------------- /tsconfig.client.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "inlineSourceMap": true, 5 | "declaration": false, 6 | "outDir": "dist/client", 7 | "lib": [ 8 | "es2015", 9 | "es2016", 10 | "dom" 11 | ] 12 | }, 13 | "include": [ 14 | "src/client/**/*" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": false, 4 | "noImplicitAny": true, 5 | "alwaysStrict": true, 6 | "strictNullChecks": true, 7 | "module": "commonjs", 8 | "target": "es6", 9 | "moduleResolution": "node", 10 | "jsx": "react" 11 | }, 12 | "include": [ 13 | "./src/**/*" 14 | ], 15 | "exclude": [ 16 | "node_modules/**/*" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": true, 5 | "declaration": true, 6 | "outDir": "dist", 7 | "lib": [ 8 | "es2015", 9 | "es2016", 10 | "dom", 11 | "esnext.asynciterable" 12 | ] 13 | }, 14 | "include": [ 15 | "src/server/**/*" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: ['./src/client/index.ts'], 5 | module: { 6 | rules: [ 7 | { 8 | test: /\.tsx?$/, 9 | use: { 10 | loader: 'ts-loader', 11 | options: { 12 | configFile: 'tsconfig.client.json', 13 | }, 14 | }, 15 | exclude: /node_modules/, 16 | }, 17 | ], 18 | }, 19 | resolve: { 20 | extensions: ['.tsx', '.ts', '.js'], 21 | }, 22 | output: { 23 | filename: 'app.js', 24 | path: path.resolve(__dirname, 'dist', 'client'), 25 | }, 26 | }; 27 | --------------------------------------------------------------------------------