Building and Deploying a Web Chat with Vite and TypeScript
Goal: migrate a vanilla chat app to Vite + TypeScript and set up production deployment with Ubuntu + Nginx + PM2 + HTTPS + GITHUB ACTIONS
0) What Is Web Chat?
“Dad, I want to use KakaoTalk too!”
The children were disappointed that they could not message their family because they did not have phones. They had tablets and computers, but messengers such as KakaoTalk require a phone number.
So I built one myself.
- Works without a phone, using only a web browser on a tablet or computer
- A private chat room just for the family
- Assign cute colors to each child's name
- Receive push notifications for new messages
I initially built it quickly with plain HTML and JavaScript, but the code grew complex as features accumulated. This post records the process of modernizing its structure with Vite + TypeScript.
1) Why Vite + TypeScript?
The reason I did not immediately adopt a framework was simple: I first needed **“a structure with the fundamentals of frontend development—modules, builds, and types—before a framework.”**
- Vite: a toolchain covering the development server with HMR and optimized production builds
- TypeScript: a type system that catches errors before runtime, during development and builds
- The code can be structured without a framework, and the same fundamentals remain useful if it later expands to Vue or React.
2) Vite Basics: A Development Server and Build Tool in One
2.1 The Two Things Vite Does
Vite optimizes development and deployment separately.
(1) Development mode: npm run dev
- Run a local development server
- File changes are reflected immediately through HMR (Hot Module Replacement)
- ES modules make the import/export flow natural
(2) Deployment build: npm run build
- Optimize the project and output it to
dist/ - Tree shaking (remove unused code), minification, hashed filenames, and more
- Optional source maps to help debugging
Simply put, it is fast during development and creates optimized output for deployment.
2.2 Why index.html Matters in Vite
In a Vite project, index.html is more than HTML: it is the app's entry point.
<script type="module" src="/src/main.ts"></script>Starting from this line, Vite:
- Loads
/src/main.ts - Traces the files imported by main.ts
- Builds the full dependency graph for development and builds
The basic Vite structure is therefore index.html → main.ts → 모듈들.
2.3 src/ vs. public/ (Important in Practice)
src/ Contains Files to Bundle
- Connect TypeScript, JavaScript, and CSS through imports
- Vite analyzes and optimizes them
public/ Contains Static Files Copied As-Is
- Copied to the dist root without processing during the build
- Also available directly as /filename on the development server
Why put the service worker in public/?
- Service workers are sensitive to paths and scope
- They often require a particular filename at the root path
→ public/firebase-messaging-sw.js is the most reliable choice
2.4 Why Use an Alias (@)?
Relative paths become messy as the code grows.
import { initFCM } from '../../../firebase/fcm' // 😵With an alias:
import { initFCM } from '@/firebase/fcm' // 🙂Keep in mind:
- Vite reads aliases from
vite.config.ts - TypeScript and the IDE read paths from
tsconfig.json
So both must match for the app to run and the editor to remain error-free.
3) TypeScript Basics: Catching Errors Early
3.1 What Is Different About TypeScript?
JavaScript errors often surface only at runtime.
Before the code runs, TypeScript:
- Checks variables, functions, and data structures
- Flags suspicious code with compile-time warnings and errors
The key point:
TS does not change the program's runtime result; it is a tool for reducing mistakes during development.
3.2 Does TypeScript Require Node?
- Browsers run JavaScript, not TypeScript.
- TS is therefore converted, or transpiled, into JS during the build.
- Tools such as Vite and tsc normally run in the Node/npm ecosystem, so Node is needed for development and builds.
But Node is not required in production when users run the deployed app.
- Users receive only the static JS, CSS, and HTML files in
dist/. - A static server such as Nginx can serve those files.
3.3 Understanding What tsconfig.json Options Mean
{
"compilerOptions": {
"strict": true,
"noEmit": true,
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"]
}
}- strict: true → aggressively catches common bugs such as possible null values and type mismatches
- noEmit: true → tsc checks types without generating JS files; Vite handles the actual build
- moduleResolution: "bundler" → resolves imports for a bundler environment such as Vite
- lib: ["DOM", ...] → includes browser DOM API types, allowing TypeScript to recognize document, HTMLElement, and others
3.4 Why TS Is Especially Useful for the DOM (Example)
Browser APIs can return null.
const el = document.getElementById('chat');To TS, the type is HTMLElement | null.
In practice, this is often enforced through a helper:
export function getElement<T extends HTMLElement>(id: string): T {
const el = document.getElementById(id);
if (!el) throw new Error(`Missing element: #${id}`);
return el as T;
}That keeps the code that follows clean:
const form = getElement<HTMLFormElement>('chatForm');
form.addEventListener('submit', ...);4) Final Project Structure
kid-chat/
├── src/
│ ├── firebase/
│ │ ├── config.ts # Firebase 초기화 및 인스턴스 export
│ │ ├── auth.ts # 인증 관련 함수 (login, logout, signup 등)
│ │ ├── chat.ts # 채팅 관련 함수 (메시지 전송, 구독, 삭제)
│ │ └── fcm.ts # FCM 푸시 알림 초기화 및 권한 요청
│ ├── types/
│ │ └── index.ts # 타입/인터페이스 정의
│ ├── utils/
│ │ └── helpers.ts # 유틸리티 함수 (formatTime, getNameClass 등)
│ ├── main.ts # 앱 진입점 (이벤트 핸들러, UI 로직)
│ ├── styles.css
│ └── vite-env.d.ts
├── public/
│ ├── firebase-messaging-sw.js # 서비스 워커(빌드 시 그대로 복사)
│ └── img/
├── functions/ # Firebase Cloud Functions (푸시 알림)
├── dist/ # 빌드 결과 (gitignore)
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
├── firebase.json
├── firestore.rules
└── CLAUDE.md # 프로젝트 문서src/ vs. public/: The Important Distinction
- src/: bundled files (TS/JS/CSS modules)
- public/: static files served or copied as-is
- Because of path and scope requirements, public/ is the most reliable place for a service worker.
5) What Is Firebase? Building an App Without Your Own Backend
5.1 What Is Firebase?
Firebase is Google's backend-as-a-service (BaaS) platform. Put simply, it lets you use the following without building a server yourself:
- Authentication: registration, login, and password changes
- Database (Firestore): message storage and real-time synchronization
- Push notifications (FCM): notifications for new messages
- Serverless functions (Cloud Functions): execute server-side logic
In a word, it is **“a collection of tools for completing an app without developing a server.”**
5.2 What Firebase Does in Kid Chat
┌─────────────────────────────────────────────────────────────────┐
│ Kid Chat 구조도 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ [브라우저] [Firebase] │
│ │
│ ┌──────────┐ 로그인 요청 ┌─────────────────┐ │
│ │ 사용자 │ ───────────────▶ │ Authentication │ │
│ │ (나윤) │ ◀─────────────── │ (인증 서비스) │ │
│ └──────────┘ 인증 토큰 └─────────────────┘ │
│ │ │
│ │ 메시지 전송 │
│ ▼ │
│ ┌──────────┐ 실시간 동기화 ┌─────────────────┐ │
│ │ 채팅 화면 │ ◀───────────────▶ │ Firestore │ │
│ │ │ │ (데이터베이스) │ │
│ └──────────┘ └─────────────────┘ │
│ │ │ │
│ │ 알림 수신 │ 새 메시지 감지 │
│ ▼ ▼ │
│ ┌──────────┐ 푸시 알림 ┌─────────────────┐ │
│ │ 알림 표시 │ ◀─────────────── │ Cloud Functions │ │
│ │ │ │ + FCM │ │
│ └──────────┘ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
5.3 The Role of Each Firebase Service
| Service | Role | Use in Kid Chat |
|---|---|---|
| Authentication | User authentication | Sign in with an account under each person's name |
| Firestore | NoSQL database | Store chat messages and synchronize them in real time |
| FCM | Push notifications | Send a notification when a new message arrives |
| Cloud Functions | Serverless functions | Automatically trigger push notifications when a message is saved |
5.4 How Real-Time Synchronization Works
One of Firestore's core features is the real-time listener.
// 메시지 컬렉션을 "구독"하면
onSnapshot(query(collection(db, 'messages'), orderBy('createdAt')), (snapshot) => {
// 다른 사람이 메시지를 보낼 때마다 이 콜백이 자동 실행됨
snapshot.docChanges().forEach((change) => {
if (change.type === 'added') {
// 새 메시지 화면에 추가
}
});
});With this approach:
- A child sends a message
- Firebase tells every subscriber that something changed
- The message appears immediately on the parent's screen
If you built the server yourself, you would need to implement WebSockets, connection management, and reconnection logic. Firestore handles all of that.
5.5 The Push-Notification Flow
1. 아이가 메시지 전송
↓
2. Firestore에 메시지 저장됨
↓
3. Cloud Functions가 "새 메시지 생성" 이벤트 감지
↓
4. FCM을 통해 다른 가족 기기에 푸시 알림 전송
↓
5. 부모가 태블릿에 "아이: 안녕!" 알림 표시
Why Cloud Functions matters in this flow:
- A client browser cannot directly send push notifications to another device
- Server-side code is needed, and Cloud Functions fills that role
6) Core Configuration Files
6.1 package.json
{
"name": "kid-chat",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"firebase": "^10.13.1"
},
"devDependencies": {
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
}- build: type-check with tsc → create an optimized build with vite build
- preview: a local/server preview that behaves like static hosting in a production environment
6.2 tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}- strict: true: this is where TypeScript's real value in preventing mistakes comes from
- noEmit: true: tsc only checks; Vite produces the actual bundle
- paths: configure TS to understand the @/ alias
6.3 vite.config.ts
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
host: true,
port: 3001,
open: true,
},
preview: {
host: true,
port: 3001,
allowedHosts: ['chat.funq.kr'],
},
build: {
outDir: 'dist',
sourcemap: true,
},
});- host: true: allow external access; localhost binding is the default
- allowedHosts: prevent “Blocked request. This host is not allowed”
7) Fix the Model with Type Definitions
src/types/index.ts
export interface ChatUser {
uid: string;
name: string;
email: string;
}
export interface Message {
id: string;
uid: string;
name: string;
text: string;
createdAt: Date | null;
}
export type NameColorClass =
| 'color-nayoon'
| 'color-soyoon'
| 'color-parent1'
| 'color-parent2'
| 'color-parent3';Benefits of defining types first:
- Clear conversion logic from Firestore to the app's internal model
- Far fewer mistakes in UI rendering and event handlers
8) Separating the Code into Modules
Firebase Configuration (src/firebase/config.ts)
- Initialize the Firebase app
- Export Auth, Firestore, and Messaging instances
- Manage the VAPID key
Authentication (src/firebase/auth.ts)
| Function | Description |
|---|---|
| getCurrentUserName() | Return the current user's name |
| setCurrentUserName(name) | Set the user name and save it in localStorage |
| onAuthChange(callback) | Listen for authentication-state changes |
| signup(name, password) | Create an account |
| login(name, password) | Log in |
| logout() | Log out |
| changePassword(current, new) | Change the password |
Chat (src/firebase/chat.ts)
| Function | Description |
|---|---|
| subscribeToMessages(callback) | Subscribe to messages in real time |
| sendMessage(text) | Send a message |
| deleteMessage(messageId) | Delete a message |
| parseMessageData(docId, data) | Parse Firestore data |
FCM (src/firebase/fcm.ts)
| Function | Description |
|---|---|
| initFCM() | Initialize FCM and register the service worker |
| requestNotificationPermission(userId) | Request notification permission and save the token |
| isFcmTokenSaved() | Check whether a token has been saved |
Utilities (src/utils/helpers.ts)
| Function | Description |
|---|---|
| getNameClass(name) | Return the color class for a name |
| nameToEmail(name) | Convert a name to a virtual email address |
| formatTime(date) | Format the time |
| getElement<T>(id) | Get a DOM element with type safety |
9) How to Run It
Development Server
npm run dev
# http://localhost:3001Type Check
npx tsc --noEmitProduction Build
npm run build
# dist/ 생성Preview the Build Output
npm run preview10) Security: Firebase's Real Security Is in Its Rules
Client configuration values in firebaseConfig are generally safe to expose, but permissions are controlled by Firestore Security Rules.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /messages/{messageId} {
allow read: if request.auth != null;
allow create: if request.auth != null;
allow delete: if request.auth != null && request.auth.uid == resource.data.uid;
}
match /fcmTokens/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
}
}Never expose:
- serviceAccountKey.json
- Firebase Admin SDK credentials
- Secret values in environment variables
11) Notes on Bundle Size
npm run build| File | Size | gzip |
|---|---|---|
| index.html | 2.70 KB | 0.93 KB |
| assets/index-*.css | 7.32 KB | 2.17 KB |
| assets/index-*.js | 500.36 KB | 114.97 KB |
The JS bundle is relatively large because it includes the Firebase SDK. If needed, consider code splitting with dynamic imports.
12) Production Deployment: Ubuntu + Nginx + PM2 + HTTPS
12.1 Nginx Reverse Proxy Configuration
/etc/nginx/sites-available/chat.conf
sudo tee /etc/nginx/sites-available/chat.conf << 'EOF'
server {
listen 80;
server_name chat.funq.kr;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
EOFApply it:
sudo ln -s /etc/nginx/sites-available/chat.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
When using a heredoc, EOF must start at the beginning of the line, with no indentation.
12.2 Running preview with PM2
npm install
npm run build
pm2 start npm --name "kid-chat" -- run previewFrequently used PM2 commands:
pm2 logs kid-chat
pm2 restart kid-chat
pm2 status
pm2 save
pm2 startup12.3 Firewall (UFW)
ss -tlnp | grep 3001
sudo ufw allow 300112.4 HTTPS (Let's Encrypt)
sudo certbot --nginx -d chat.funq.krCertbot automatically:
- Issues a certificate
- Adds HTTPS configuration
- Redirects HTTP to HTTPS
- Registers an automatic renewal schedule
13) Production Troubleshooting
13.1 "Blocked request. This host is not allowed"
Vite's security policy blocks unapproved hosts. → Add the domain to preview.allowedHosts in vite.config.ts.
13.2 Cannot Connect from Outside
- Check the
host: truesetting - Check the firewall with
sudo ufw status - Check the listening port:
ss -tlnp | grep 3001
14) CI/CD
I applied the same CI/CD approach described in the post below.
Following that post connects the build, deployment, and PM2 restart into one flow.
15) Wrapping Up
The gain from this work was applying “the fundamentals to establish before adopting a framework” to a real service.
- Module separation reduces how much code must be understood or changed at once
- Strict TS catches most mistakes during development
- Vite handles both fast development with HMR and deployment builds
- Completing production deployment turns it into a service people can actually use
Most importantly, the children can now send “Dad, I love you!” from their tablets.





Comments
Korean and English pages share this conversation.
Loading comments…