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:

Code
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:

Bash
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.

HTML / XML
<!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.

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.

JavaScript
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.

JavaScript
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.

JSON
{
  "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:

Code
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:

Bash
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:

Bash
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:

Bash
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:

JavaScript
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:

JavaScript
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.