How I Built My First Full-Stack App
The Idea
It all started when I wanted a personal to‑do list that could sync across my phone and laptop without relying on a third‑party service. I sketched a few screens on a napkin, imagined a clean React UI, and thought, "Why not build it myself?" The excitement of creating something from scratch was the spark I needed to dive into full‑stack development.
Choosing the Stack
I wanted a stack that was both beginner‑friendly and powerful enough for production. After a quick Google search and a chat with a mentor, I settled on:
- Frontend: React with Vite for lightning‑fast hot reloads.
- Backend: Node.js with Express – simple, minimal, and JavaScript everywhere.
- Database: SQLite for local development, later swapped to PostgreSQL on the cloud.
- Deployment: Render (free tier) for both the API and the static site.
Setting Up the Project
First, I created two folders – client and server. In the terminal I ran:
# Create React app with Vite
npm create vite@latest client -- --template react
cd client
npm install
# Set up Express server
cd ..
mkdir server && cd server
npm init -y
npm install express cors sqlite3
I added a basic package.json script to start both parts concurrently using concurrently:
{
"scripts": {
"dev": "concurrently \"npm run dev --prefix client\" \"npm run start --prefix server\""
},
"devDependencies": {
"concurrently": "^8.0.0"
}
}
This tiny setup let me run npm run dev from the root and see both the React dev server and the Express API live.
Building the Frontend
In client/src/App.jsx I wrote a simple component that fetched todos from the API and displayed them:
import { useEffect, useState } from "react";
function App() {
const [todos, setTodos] = useState([]);
const [newTodo, setNewTodo] = useState("");
useEffect(() => {
fetch("/api/todos")
.then(res => res.json())
.then(setTodos);
}, []);
const addTodo = async () => {
const res = await fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: newTodo })
});
const created = await res.json();
setTodos([...todos, created]);
setNewTodo("");
};
return (
<div className="container">
<h1>My Todo List</h1>
<ul>
{todos.map(t => (
<li key={t.id}>{t.text}</li>
))}
</ul>
<input value={newTodo} onChange={e => setNewTodo(e.target.value)} placeholder="New todo" />
<button onClick={addTodo}>Add</button>
</div>
);
}
export default App;
Notice the relative /api path – Vite’s proxy feature will forward those calls to the Express server during development.
Creating the Backend API
Inside server/index.js I set up a tiny Express app:
const express = require("express");
const cors = require("cors");
const sqlite3 = require("sqlite3");
const path = require("path");
const app = express();
app.use(cors());
app.use(express.json());
// Initialize SQLite DB
const db = new sqlite3.Database(path.join(__dirname, "todos.db"));
db.run("CREATE TABLE IF NOT EXISTS todos (id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT)");
app.get("/api/todos", (req, res) => {
db.all("SELECT * FROM todos", [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows);
});
});
app.post("/api/todos", (req, res) => {
const { text } = req.body;
db.run("INSERT INTO todos (text) VALUES (?)", [text], function (err) {
if (err) return res.status(500).json({ error: err.message });
res.json({ id: this.lastID, text });
});
});
const PORT = process.env.PORT || 4000;
app.listen(PORT, () => console.log(`Server listening on ${PORT}`));
That was enough to get CRUD operations working locally. I also added a tiny error‑handling middleware later, but the core logic stayed under 50 lines.
Connecting Frontend and Backend
During development Vite’s proxy option in vite.config.js made the connection seamless:
export default defineConfig({
server: {
proxy: {
"/api": "http://localhost:4000"
}
}
});
When I built the React app for production (npm run build), the static files landed in client/dist. I then served those files from Express so the whole thing could run from a single Node process:
app.use(express.static(path.join(__dirname, "../client/dist")));
app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "../client/dist/index.html"));
});
Now the API and the UI lived side‑by‑side, just like a real production app.
Deploying to the Cloud
Render made deployment painless. I created two services:
- Web Service – points to the
serverfolder, runsnode index.js, and automatically detects thePORTenv variable. - Static Site – points to the
clientfolder, runsnpm install && npm run build, and serves thedistfolder. Both services were linked via a private network, so the frontend could call the backend using the internal URL Render provides. After a few minutes the app was live athttps://my-todo-list.onrender.com.
Lessons Learned
- Start Small: I kept the MVP to a single model (todos). Adding more features later felt natural instead of overwhelming.
- Consistent Language: Using JavaScript on both client and server eliminated context‑switching and sped up debugging.
- Environment Variables Matter: Storing the DB URL, API keys, and secret salts in
.envfiles saved me from hard‑coding secrets. - Error Handling is Crucial: A missing
try/catchin the API caused the whole app to crash once. Adding a global error handler made the system resilient. - Testing Early: A few Jest tests for the Express routes caught a typo before I pushed to production.
Conclusion
Building my first full‑stack app was a rollercoaster of excitement, frustration, and aha moments. By picking a simple stack, breaking the problem into bite‑size pieces, and leveraging modern tooling like Vite and Render, I turned a napkin sketch into a live product in under two weeks. If you’re on the fence about building your own full‑stack project, grab an idea that matters to you, follow the steps above, and remember: the best way to learn is by doing.