A web application often has two parts: a frontend that users interact with and a backend API that handles business logic, database operations, and authentication. Hosting both parts requires a clear deployment setup so the frontend can load correctly and communicate with the API.
For example, you might build a React application that displays product information and a Node.js API that retrieves products from a database. The frontend consists of static files such as HTML, CSS, and JavaScript, while the backend runs as a server-side application.
Heroku can host the backend application, but static frontend hosting needs to be configured separately or served through the same application. The right approach depends on your framework, deployment requirements, and whether you want one deployment or two.
In this article, we will build a simple static website and backend API, configure them for deployment on Heroku, and cover the settings that commonly cause problems.
Understanding the Hosting Architecture
Before writing the deployment configuration, decide how the frontend and backend will be hosted.
There are two common approaches.
Approach | How it works | Best suited for |
|---|
One application | The backend serves both the API and static frontend files | Small applications and simpler deployments |
Two applications | The frontend and backend are deployed separately | Independent scaling and separate release cycles |
A static frontend does not need a server-side runtime to generate each page. A web server or static hosting platform can deliver its files directly.
The backend API, on the other hand, needs a running application process to handle requests.
One important consideration is that Heroku's deployment model and available buildpacks determine how an application is built and started. Do not assume that uploading an HTML folder alone creates a complete static hosting setup. You need a deployment configuration that matches the application architecture.
Step 1: Create the Project Structure
For this example, we will use a Node.js backend with a simple HTML frontend.
Create the following directory structure:
my-heroku-app/
├── public/
│ ├── index.html
│ ├── style.css
│ └── app.js
├── server.js
├── package.json
└── Procfile
The public directory contains the frontend files. The server.js file runs the backend API and serves the static content.
This approach keeps the deployment simple because the frontend and API are part of one application.
Create the project directory and initialize the Node.js project:
mkdir my-heroku-app
cd my-heroku-app
npm init -y
npm install express
These commands create the project configuration and install Express, which we will use to serve static files and define the API endpoint.
Step 2: Build the Static Frontend
Create a file named public/index.html.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Heroku Demo</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<main>
<h1>Product Dashboard</h1>
<p id="status">Loading products...</p>
<ul id="products"></ul>
</main>
<script src="/app.js"></script>
</body>
</html>
The page contains a heading, a status message, and a list where product information will appear.
The frontend uses relative paths for its CSS, JavaScript, and API requests. This is useful when the website and API share the same origin because you do not need to hardcode a domain name.
Next, create public/style.css.
body {
font-family: Arial, sans-serif;
margin: 40px;
background: #f5f5f5;
}
main {
max-width: 700px;
margin: auto;
padding: 24px;
background: white;
border-radius: 8px;
}
h1 {
color: #222;
}
li {
margin: 10px 0;
}
This stylesheet provides basic formatting for the page. You can replace it with your own design without changing the backend API.
Step 3: Create the Backend API
Create server.js in the project root.
const express = require("express");
const path = require("path");
const app = express();
const port = process.env.PORT || 3000;
const products = [
{ id: 1, name: "Laptop" },
{ id: 2, name: "Keyboard" },
{ id: 3, name: "Monitor" }
];
app.use(express.static(path.join(__dirname, "public")));
app.get("/api/products", (req, res) => {
res.json(products);
});
app.get("/health", (req, res) => {
res.status(200).json({ status: "ok" });
});
app.listen(port, "0.0.0.0", () => {
console.log(`Server listening on port ${port}`);
});
This application does three things.
First, Express serves files from the public directory. When a browser requests the root path, it can return index.html.
Second, the /api/products endpoint returns a JSON array. In a real application, this data would usually come from a database or another service.
Third, the /health endpoint provides a simple way to check whether the application can respond to an HTTP request.
The application reads the port from process.env.PORT because the hosting platform supplies the port the application should use. The fallback port supports local development.
Binding to 0.0.0.0 allows the server to listen on the container or runtime network interface rather than only the local loopback interface.
Step 4: Connect the Frontend to the API
Create public/app.js.
async function loadProducts() {
const status = document.getElementById("status");
const list = document.getElementById("products");
try {
const response = await fetch("/api/products");
if (!response.ok) {
throw new Error("Failed to load products");
}
const products = await response.json();
list.replaceChildren();
for (const product of products) {
const item = document.createElement("li");
item.textContent = product.name;
list.appendChild(item);
}
status.textContent = "Products loaded successfully.";
} catch (error) {
status.textContent = "Unable to load products. Please try again.";
console.error("Product request failed:", error);
}
}
loadProducts();
The browser requests /api/products from the same origin that served the frontend.
Using a relative API path keeps local development and production configuration simpler. You do not need to update the JavaScript every time the application domain changes.
The code also checks response.ok because fetch does not automatically throw an exception when the server returns an HTTP error status such as 500.
For a production application, add appropriate loading states, error reporting, and authentication where needed. Do not expose sensitive backend details in messages displayed to users.
Step 5: Configure the Node.js Application for Deployment
Open package.json and make sure the scripts section includes a start command.
{
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^5.1.0"
}
}
This is an illustrative package configuration. Your actual dependency versions should be managed and tested for your project, and you should retain any other required fields generated by npm.
For reproducible builds, commit package-lock.json and use the appropriate locked dependency installation process in your deployment pipeline.
Create a file named Procfile in the project root:
web: npm start
The Procfile declares the web process that Heroku should run for this application.
Make sure the filename is exactly Procfile, without an additional file extension.
Step 6: Deploy the Application to Heroku
Before deploying, confirm that your Heroku account and command-line access are configured.
From the project directory, initialize Git if the project is not already in a repository:
git init
git add .
git commit -m "Prepare application for deployment"
Create or select a Heroku application using the deployment workflow supported by your account.
If your project uses Git-based deployment and the appropriate build setup, the deployment command may look like this:
heroku create
git push heroku main
The exact workflow depends on your configured Heroku deployment method and the branch you use. Current platform capabilities, buildpack requirements, and account availability should be confirmed before choosing a deployment approach.
After deployment, inspect the application logs:
heroku logs --tail
Look for startup errors, missing dependencies, incorrect start commands, or port configuration problems.
Once the application is running, open its assigned application domain. The homepage should display the frontend, while the /api/products endpoint should return JSON.
Step 7: Configure Frontend and Backend as Separate Applications
If you want to deploy the frontend and backend independently, the architecture changes slightly.
The backend application exposes API endpoints. The frontend is deployed to a static hosting provider or another service that can serve HTML, CSS, and JavaScript files.
The frontend then uses the backend's public API address:
const API_BASE_URL = "https://your-api-domain.example";
async function getProducts() {
const response = await fetch(
`${API_BASE_URL}/api/products`
);
if (!response.ok) {
throw new Error("Unable to retrieve products");
}
return response.json();
}
Replace the example address with your actual backend domain before building the frontend.
When the frontend and backend use different origins, configure Cross-Origin Resource Sharing (CORS) on the backend to allow the intended frontend origin.
For an Express application, a CORS configuration might look like this:
const cors = require("cors");
app.use(cors({
origin: process.env.FRONTEND_ORIGIN
}));
Install the cors package and set FRONTEND_ORIGIN to the exact trusted frontend origin in the deployment environment.
CORS controls which browser-based origins can read responses. It is not an authentication mechanism and does not prevent non-browser clients from calling the API.
If the API uses cookies or other credentials, additional CORS and cookie settings are required. Avoid allowing every origin when the application handles sensitive data.
Common Deployment Problems and Solutions
Problem | Likely cause | What to check |
|---|
Application fails to start | Incorrect start command or missing dependency | Procfile, package.json, and deployment logs |
Application listens on the wrong port | Hardcoded port number | Use process.env.PORT |
Homepage returns an error | Incorrect static file directory | Express static path and deployed files |
API returns a 404 | Incorrect route or request path | API route and frontend fetch URL |
Browser reports a CORS error | Frontend and API use different origins | Backend CORS configuration |
Works locally but fails after deployment | Different environment or dependency configuration | Environment variables, package lockfile, and runtime settings |
API returns an unexpected error | Backend exception or unavailable dependency | Application logs and downstream services |
Best Practices for Production Deployment
Keep environment-specific settings outside the source code. Database credentials, API keys, and other secrets should be supplied through secure configuration rather than committed to Git.
Use a lockfile to keep dependency installation predictable. Run tests before deployment and verify both the homepage and API endpoints after each release.
If the frontend and backend are deployed separately, document the API base URL and configure CORS for the intended origins. For applications with authentication, enforce authorization on the backend rather than relying on frontend checks.
Also consider logging, health checks, dependency availability, and a rollback procedure. A successful deployment does not necessarily mean every application feature is working correctly.
Advantages and Disadvantages
Hosting the frontend and API in one application simplifies routing, deployment, and local development. It also avoids cross-origin requests when both are served from the same origin.
However, a combined application ties the frontend and backend release cycles together. A change to one part may require redeploying the entire application.
Separate deployments allow the frontend and backend to be released and scaled independently. The trade-off is additional configuration for API addresses, CORS, deployment pipelines, and monitoring.
Choose the approach based on your application's size, security requirements, and deployment needs.
Summary
Hosting a static website and backend API requires a clear deployment structure. A simple option is to serve the frontend files and API routes from the same Node.js application. For larger applications, you can deploy the frontend and backend separately and configure their communication explicitly.
Before deploying, configure the start command, use the port provided by the hosting environment, manage dependencies consistently, and check application logs after release.
Finally, verify that the frontend loads correctly, the API returns the expected data, and security settings match your deployment architecture. These checks help make deployments easier to maintain and troubleshoot.
Join the conversation! Your thoughts help the community grow.