Full-Stack JavaScript Project Setup with Node.js and Vite
What you’ll build
Setting up a full-stack JavaScript project from scratch means wiring a Node.js backend to a browser frontend so they can talk to each other during development. This tutorial walks through the exact steps: folder layout, dependency installation, Express server code, Vite scaffolding, proxy configuration, and a shared dev command that starts both servers together. By the end you will have a working project template you can reuse for your own apps.
Your finished project will include:
- A root project folder with two separate sub-projects inside:
backend/andfrontend/ - A Node.js server (Express) running on
localhost:3000that handles API requests - A Vite-powered frontend on
localhost:5173that fetches data from the backend with the click of a button - A combined workflow where both servers start together with one command
This is the foundation every full-stack JavaScript app builds on.
Prerequisites: basic JavaScript and command-line knowledge. No prior Node.js or Express experience needed. About 15 minutes.
Project structure and folder organisation
Before writing any code, it helps to understand how a full-stack JavaScript project is organised.
Think of your project as a house with two rooms. Each room has its own utilities, its own furniture, and its own set of instructions. The backend/ folder is one room: it runs on the server and handles data. The frontend/ folder is the other room: it runs in the browser and handles what the user sees.
Each room has its own package.json file. This file keeps track of which packages (libraries) that room depends on. Keeping them separate prevents conflicts: the backend might need Express, while the frontend might need Vite, and those two things never need to know about each other.
Shared files like .gitignore (which tells Git which files to ignore) and README.md (documentation) live at the root level, where they apply to both rooms.
my-fullstack-app/
├── backend/ # Server-side code lives here
│ ├── src/
│ │ └── index.js # The main server file
│ └── package.json # Backend dependencies
├── frontend/ # Browser-side code lives here
│ ├── src/
│ │ └── main.js # The main frontend script
│ ├── index.html
│ └── package.json # Frontend dependencies
├── .env # Secrets and environment settings (not committed to git)
├── .gitignore # Tells git to ignore node_modules, .env, etc.
└── README.md # Project documentation
Notice there is no node_modules/ folder in the tree. That folder holds all installed packages and should never be committed to version control. Your .gitignore file will make sure of that.
Initialising Git
If you are starting from scratch, initialise a Git repository at the project root:
git init
git remote add origin <your-repo-url>
You will commit your code after writing the initial files.
Setting up the backend with Express
The backend is a Node.js server built with Express. Its job is to receive requests from the frontend, process them, and send back a response. When a user clicks a button in the browser, the frontend sends a request to the backend, which does some work and replies with data.
Initialising the backend
Open your terminal and run these commands to create the backend folder and install the necessary packages:
mkdir backend && cd backend
npm init -y
npm install express cors dotenv
npm install --save-dev nodemon
Each command serves a specific purpose. mkdir backend && cd backend creates the folder and moves into it. npm init -y generates a package.json with default settings so you don’t have to answer the interactive prompts. npm install express cors dotenv pulls in three dependencies at once: Express for the HTTP server, CORS for cross-origin access, and dotenv for loading environment files. npm install --save-dev nodemon adds nodemon as a dev dependency: it watches your files and restarts the server whenever you save changes, which saves you from manually stopping and restarting during development.
A minimal Express server
Create the file backend/src/index.js and add the following code:
// backend/src/index.js
const express = require('express'); // Import the Express framework
const cors = require('cors'); // Import CORS middleware
require('dotenv').config(); // Load environment variables from .env
const app = express(); // Create a new Express application
const PORT = process.env.PORT || 3000; // Use port from .env, or 3000 as fallback
// Middleware: these run before your routes
app.use(cors()); // Allow cross-origin requests from the frontend
app.use(express.json()); // Parse JSON request bodies automatically
// Routes: define endpoints the frontend can call
// Health check: confirms the server is running
// GET /api/health
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// GET /api/data: responds with a data object
app.get('/api/data', (req, res) => {
res.json({ message: 'Hello from the backend', data: [1, 2, 3] });
});
// Start the server
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
The middleware section runs on every request before it reaches your route handlers. app.use(cors()) sets the right headers so the browser allows the request, and app.use(express.json()) parses incoming JSON payloads automatically. The two route definitions handle GET requests: /api/health confirms the server is alive, and /api/data returns a simple object the frontend can display.
To start the server in development mode (with auto-restart on file changes), run:
npx nodemon src/index.js
The server will start on http://localhost:3000. Leave this terminal open; nodemon will restart the server automatically whenever you save changes to a file.
Setting up the frontend with Vite
The frontend is what runs in the user’s browser. Vite is a modern build tool that makes frontend development fast. It serves your HTML, CSS, and JavaScript files, and it refreshes the page automatically when you save changes, the same way nodemon does for the backend.
Scaffolding the frontend
Open a second terminal, go back to the root of your project, and create the frontend:
cd .. # Go back to the project root
mkdir frontend && cd frontend
npm create vite@latest . -- --template vanilla
npm install
npm create vite@latest . -- --template vanilla scaffolds a minimal Vite project in the current directory. The vanilla template gives you plain JavaScript without any framework. Other templates like react or vue are available if you prefer those.
Configuring the proxy
During development, the frontend runs on http://localhost:5173 and the backend runs on http://localhost:3000. The browser considers these different origins, so it would normally block requests from the frontend to the backend. There are two ways to fix this:
- Enable CORS on the backend (we already did this in
index.js) - Configure Vite to proxy API requests, so requests to
/api/*are forwarded to the backend automatically
The proxy approach is cleaner because it means your frontend code can use relative paths like /api/data instead of http://localhost:3000/api/data, and it works without the browser triggering CORS checks. All API calls stay on the same origin from the browser’s perspective.
Create or update frontend/vite.config.js:
// frontend/vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
server: {
proxy: {
// Any request from the frontend to /api gets forwarded to the backend
'/api': {
target: 'http://localhost:3000', // Backend address
changeOrigin: true, // Update the Origin header to match target
},
},
},
});
With the proxy in place, your development server is a middleman for API calls. Any request path starting with /api gets forwarded to port 3000, and the browser sees the response as if it came from port 5173. This is the recommended setup for most full-stack JavaScript projects because it keeps production and development configurations close to each other.
The frontend HTML and JavaScript
Replace the contents of frontend/index.html with this:
<!-- frontend/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Full-Stack App</title>
</head>
<body>
<h1>Full-Stack Demo</h1>
<button id="fetchBtn">Fetch Backend Data</button>
<pre id="output">Click the button to see data from the backend</pre>
<script type="module" src="/src/main.js"></script>
</body>
</html>
The page has a single button and a <pre> element that is the output area. When the button is clicked, the JavaScript in main.js sends a GET request to /api/data. The proxy in vite.config.js forwards that request to the backend, so the relative URL works even though the two servers run on different ports.
Replace frontend/src/main.js with:
// frontend/src/main.js
// When the button is clicked, fetch data from the backend
document.getElementById('fetchBtn').addEventListener('click', async () => {
try {
// Fetch sends a GET request to /api/data
// Vite's proxy forwards it to http://localhost:3000/api/data
const response = await fetch('/api/data');
// Check if the server responded successfully
if (!response.ok) {
throw new Error(`Server error: ${response.status}`);
}
// Parse the JSON response
const result = await response.json();
// Display the result in the <pre> element
document.getElementById('output').textContent = JSON.stringify(result, null, 2);
} catch (error) {
// If anything goes wrong, show the error message
console.error('Fetch failed:', error.message);
document.getElementById('output').textContent = `Error: ${error.message}`;
}
});
The fetch call uses a relative URL (/api/data) because of the Vite proxy configured earlier. Without the proxy, the same code would need the full http://localhost:3000/api/data address and would trigger a CORS preflight request. The async/await pattern here is standard for browser fetch calls: wait for the response, check the status, parse the JSON body, and display the result.
To start the frontend dev server:
npm run dev
The frontend will be available at http://localhost:5173. Click the button and the page will fetch data from the backend and display it.
Running both servers with concurrently
Right now you have two terminals open: one running the backend with nodemon, one running the frontend with Vite. That works, but it is inconvenient. The concurrently package lets you run both commands in a single terminal.
Go back to the project root, initialise it as a Node project, and install concurrently:
cd ..
npm init -y
npm install --save-dev concurrently
Open package.json at the root level and update the scripts section. The --names flag labels each process, and --prefix-colors gives each label a distinct colour so you can tell the output streams apart at a glance. The server and client scripts are regular npm scripts that concurrently spawns as parallel child processes. Both scripts are defined inside the same package.json at the project root so they are easy to find and edit:
{
"name": "my-fullstack-app",
"version": "1.0.0",
"scripts": {
"dev": "concurrently --names \"backend,frontend\" --prefix-colors \"blue,green\" \"npm run server\" \"npm run client\"",
"server": "cd backend && npx nodemon src/index.js",
"client": "cd frontend && npm run dev"
}
}
Now you can start both servers with a single command. The npm run dev script calls concurrently, which launches the server and client simultaneously and merges their output into a single unified terminal stream:
npm run dev
You will see output from both processes in the same terminal, each labelled with its name and a colour so you can tell at a glance whether a line came from the Express backend or the Vite frontend:
[backend] Server running on http://localhost:3000
[frontend] VITE v5.x.x ready in 200ms
[frontend] Local: http://localhost:5173/
Environment variables and configuration
Environment variables let you store settings that change between environments (development, production) or that should stay private (API keys, database passwords). They live in a .env file and get loaded into process.env when the server starts.
Backend environment variables
In the backend/ folder, create a file called .env:
# backend/.env
PORT=3000
NODE_ENV=development
Important: Add .env to your .gitignore file so it never gets committed to version control, because anyone cloning your project creates their own .env with their own port and environment:
# .gitignore
node_modules/
.env
In your backend code, dotenv loads the .env file automatically when you call require('dotenv').config(). You can then read any variable with process.env.VARIABLE_NAME. The example below shows how the server entry point picks up these settings:
// backend/src/index.js (already has this line near the top)
require('dotenv').config();
const PORT = process.env.PORT || 3000; // Use 3000 if .env doesn't set PORT
console.log(process.env.NODE_ENV); // Output: development
Frontend environment variables
Frontend code runs in the browser, so it does not have access to process.env. Vite handles this differently: any variable the frontend should see must be prefixed with VITE_. This prefix rule prevents backend-only secrets from leaking into the browser bundle.
Create frontend/.env:
# frontend/.env
VITE_API_URL=/api
The import.meta.env object is a Vite-provided replacement for process.env that only exposes variables with the VITE_ prefix. This keeps your backend secrets on the server while letting the API base URL safely reach the browser. You access it the same way you would any other configuration object:
// frontend/src/main.js
const API_URL = import.meta.env.VITE_API_URL || '/api';
console.log(API_URL); // Output: /api
Variables without the VITE_ prefix are not exposed to the frontend. This keeps your backend-only secrets out of the browser.
Common gotchas and pitfalls
When you build a full-stack JavaScript project for the first time, a few issues come up almost every time. Knowing them upfront saves time.
Port conflicts
If two services try to use the same port, one will fail. The Express backend defaults to port 3000, and Vite’s dev server defaults to 5173. Make sure both are different. If something fails to start, check whether another process is already using that port with lsof -i :3000 or the equivalent on your OS.
CORS errors
If you see an error in the browser console like Access to fetch at 'http://localhost:3000' from origin 'http://localhost:5173' has been blocked by CORS policy, the backend is not allowing requests from the frontend. Fix this by either:
- Adding
app.use(cors())to the Express server, or - Configuring the Vite proxy as described above
CommonJS vs ES modules
Node.js defaults to CommonJS syntax, which uses require() and module.exports. If you want to use modern ES Modules syntax with import and export, you need to change your backend code to use ESM imports throughout and add "type": "module" to your backend’s package.json. This is an advanced change: all your require() calls must become import statements, and import paths must include the file extension. Most new Node.js projects use ES Modules, but beginners typically start with the CommonJS setup shown in this tutorial.
Missing dependencies after cloning
If you clone a project and npm install fails with errors like Cannot find module 'express', you forgot to install dependencies. Always run:
npm install # Installs root dependencies
cd backend && npm install # Installs backend dependencies
cd ../frontend && npm install # Installs frontend dependencies
process.env is not available in the browser
The process.env object is a Node.js feature. Frontend JavaScript runs in the browser, where it does not exist. If you need a variable in the frontend, use Vite’s VITE_ prefix system described above.
Production build and next steps
When you are happy with your app and ready to deploy, the frontend needs to be built into optimised static files. Vite handles this:
cd frontend
npm run build
This creates a dist/ folder containing minified HTML, CSS, and JavaScript. You can serve these files from your Express server so that the same port serves both the frontend and the API. The snippet below adds a static file middleware and a catch-all route:
// In backend/src/index.js, add near the top
const path = require('path');
// Serve the built frontend files
app.use(express.static(path.join(__dirname, '../../frontend/dist')));
// Catch-all route: sends index.html for any route the frontend handles
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../../frontend/dist/index.html'));
});
Now the Express server handles both the API and the frontend from a single port.
From here, a natural next step is to add a database (see the Express documentation for integration guides), implement authentication, or build more API routes. The next tutorial in this series covers each of those topics in detail.
Next steps
- Node.js Error Handling Patterns — learn how to handle errors consistently across your Express routes
- Node.js Logging and Monitoring — set up structured logging and health checks for your backend
- Node.js Validation and Sanitization — validate and sanitize input before it reaches your logic
See also
- Number.isInteger() — validate that a value is a whole number
- Express — the official Express documentation
- Vite — Vite’s official guide with configuration reference
Previous: Full-Stack File Uploads Next: Full-Stack Client-Server Communication