DEV Community

Cover image for How to develop a UI and an API at the same time, with no headaches
Patrice Ferlet
Patrice Ferlet

Posted on

How to develop a UI and an API at the same time, with no headaches

You develop a WEB interface with ParcelJS, Angular, Vue, React... And of course, this interface will have to contact the API that you code in parallel. So you'll spend a little time fighting with CORS headers, having to configure the access point to take into account the production environment...

It's a shame, though. I am a strong supporter of the separation of view and control, and I think that a reverse-proxy is of course important in production. But, you are certainly coding your backend with a language and/or framework that knows how to serve WEB pages and static content.

This article shows the method with ParcelJS, but it will be easy enough to adapt it for any other bundler that allows to do automatic build. The main idea, as you will have understood, is not to use the built-in web server, but to make your backend application do it.

The "bad" workflow

Before seeing the method I prefer to use, let me show you what I often see in many projects. The bad method...

Using ParcelJS, it makes it very easy to start developing an interface. Because parcel builds the application and use a "live reload" system. So, using parcel or parcel serve is simple.

This will make parcel start a WEB service on port 1234, we just have to go to the page https://localhost:1234 and that's it.

But what we are interested in is that our WEB application connects to an API that we develop in Go or Python, for example.

Obviously, the API will listen to another port, and this gets complicated.

  • Above all, the application should be configured to connect to the port of the API, in the development phase this is different from the production phase
  • but even if we work locally, it is likely that your navigator prevents access to the API due to the management of "Cross Origin" headers (CORS).

And then, usually, I see project managers to install a reverse proxy, manipulating headers, making the Web UI to be able to connect the API to another port, and of course using a "settings" file that is changed from developmment environment to production.

That's a bit complex, isn't it?

So what can we do? How to ease the developmment phase?

We make our API the web server!

And you'll see that this will resolve both development and production builds. Yeah.

Let's go for a little project

We start by creating a working directory, and we will do this, for example, in Go (we will see later for the Python with Flask)

mkdir -p ~/Project/demo-web1
cd !$
# we create the "ui" here
mkdir -p ui
cd !$
# then create the project
yarn init
yarn add --dev parcel rimraf
Enter fullscreen mode Exit fullscreen mode

Of course, you can add TypeScript, ESLint, and whatever the tools you need.

Then, change the package.json file to set up scripts and the "source" entrypoint + add scripts:

{
  //...
  "source": "src/index.html",
  //...
  "scripts": {
    "start": "rimraf dist .parcel-cache; parcel watch",
    "build": "rimraf dist .parcel-cache; parcel build"
  },
  //...
}
Enter fullscreen mode Exit fullscreen mode

It's important to change the "main" entry point to "source" and to point it on the "index.html" file.

OK, now create src/index.html page:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>Exemple</title>
    <script src="./main.js" type="module"></script>
  </head>
  <body>
    <h1>Exemple</h1>
    <div id="message"></div>
    <button id="button">Click me</button>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Also, create this script (src/main.js):

// call the /api/demo
function callApi() {
  fetch("/api/demo")
    .then((response) => response.json())
    .then((data) => {
      document.querySelector("#message").innerHTML = data.date;
    });
}

document.addEventListener("DOMContentLoaded", () => {
  document.querySelector("#button").addEventListener("click", callApi);
});
Enter fullscreen mode Exit fullscreen mode

Note that the call with fetch does not change the port, does not provide a particular url, it will be a call made on the same host. Since we will be serving the application via our API

So, at this time, you should have this structure:

└── ui
    ├── node_modules
    ├── package.json
    ├── src
    └── yarn.lock
Enter fullscreen mode Exit fullscreen mode

Did you realize that we don't use parcel or parcel serve but parcel watch ?

We now don't ask parcel to "serve" the interface, we only need to build tha application to dist folder. And we will serve this with our API/Server! That's all!

It's time to create the API and server.

Let's go up a level to demo-web1 – we'll create the API here, but feel free to create a subdirectory if you like. You'll just have to change the path of the static folder.

In short!

# if you're still in the ui directory, go up
cd ..

# create the api
go mod init example
Enter fullscreen mode Exit fullscreen mode

And here, create a main.go file that contains this:

package main

import (
    "fmt"
    "net/http"
    "time"
)

func main() {

    // respond to /api/demo, send the date in json format
    http.HandleFunc("/api/demo", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        t := time.Now()
        w.WriteHeader(http.StatusOK)
        fmt.Fprintf(w, `{"date": "%s"}`, t.Format(time.RFC3339))
    })

    // serve the ui/dist directory as static
    http.Handle("/", http.FileServer(http.Dir("ui/dist")))

    // start the server
    log.Println("Starting server on port 8080")
    http.ListenAndServe(":8080", nil)
}
Enter fullscreen mode Exit fullscreen mode

You should now have this project structure:

├── go.mod
├── main.go
└── ui
    ├── node_modules
    ├── package.json
    ├── src
    │   ├── index.html
    │   └── main.js
    └── yarn.lock
Enter fullscreen mode Exit fullscreen mode

We can now access two endpoints:

  • "/api/demo" returns the date (yes, it's fast-coded, we could use the encoding/json package, but that's just to show you)
  • any other path will try to go to ui/dist

ui/dist is where parcel will transpile our web interface - it doesn't exists yet, but it will be there.

OK, so now we can make sure that our UI is completed at each modification, on one side, and we launch the API on the other. To restart the Go application at each change, I often use entr which is very easy to use.

The "entr" command which is a standard package in Linux (install this with dnf or apt) and could be found for Mac.

We will now run two commands:

  • one that will compile the web interface code continuously, at each change
  • another that will simply recompile the API

They have to run in parallel, so we will need two terminals for the moment.

# in the first terminal
cd ui && yarn run start

# in the second terminal
find . -name "*.go" | entr -r go run main.go
Enter fullscreen mode Exit fullscreen mode

For those who have tmux on their machine, a nice little trick is to run the following command. It can work perfectly well in a Makefile by the way

tmux new-session \
    "cd ui && yarn run start;" \
    split-window -h \
    "find . -name '*.go' | entr -r go run main.go"

Then go to http://localhost:8080, click on the button, and the date is displayed.

Same with Flask

With Python and Flask (or Quart), you can do the same.

You can, if you want, remove the go.mod and main.go file if you created them earlier.

OK, let's create a virtualenv, and install Flask.

# ensure you're not in "ui" directory then

python -mvenv venv
source venv/bin/activate
pip install flask
Enter fullscreen mode Exit fullscreen mode

That's now OK – time to create our application.

Create the main.py file and type:

from datetime import datetime

from flask import Flask, jsonify

app = Flask(__name__)

# use ui/dist as static
app.static_folder = "ui/dist"


@app.route("/api/demo")
def demo():
    """return the date and hour in json format"""
    date = datetime.now().strftime("%d/%m/%Y %H:%M:%S")
    return jsonify(date)


@app.route("/")
@app.route("/<path:path>")
def index(path="index.html"):
    """Serve static files"""
    return app.send_static_file(path)


if __name__ == "__main__":
    app.run(debug=True)

Enter fullscreen mode Exit fullscreen mode

One more time, start the UI generation, and now the Python service:

# in on terminal
cd ui && yarn run start

# in anther one (ensure you activated the venv before)
python main.py
Enter fullscreen mode Exit fullscreen mode

And go to http://localhost:5000

In debug mode, Flask reloads the server when Python sources changes. No need to use entr.

One more time, any changes in the UI is automatically compiled.

And so, for deployment ?

Because the parcel watch process compiles everythin in dist directory, you will only need to build sources. The application, whatever if you use Go, Python, or any other language, will find the static files.

It's very cool when we want to use Docker (or Podman) in multistage build:

For the Go version, we can use a "scratch" image to only get UI and server binary. No need to get a Linux distribution.

FROM node:alpine as js-build
COPY ./ui /app
RUN set -xe \
    cd /app; \
    yarn install; \
    yarn run build;

FROM golang:alpine as go-build
COPY ./ /app
RUN set -xe; \
    cd /app; \
    go mod tidy; \
    CGO_ENABLED=0 go build -o app.bin *.go

FROM scratch
COPY --from=js-build /app /app/ui
COPY --from=go-build /app/app.bin /app/app.bin
EXPOSE 8080
WORKDIR /app
CMD ["/app/app.bin"]
Enter fullscreen mode Exit fullscreen mode

For Python, we can make approximately the same or use gunicorn to serve the application.

FROM node:alpine as js-build
COPY ./ui /app
RUN set -xe \
    cd /app; \
    yarn install; \
    yarn run build;

FROM python:alpine
COPY ./ /app
COPY --from=js-build /app /app/ui
RUN set -xe; \
    pip install flask gunicorn

EXPOSE 8000
WORKDIR /app
CMD ["gunicorn", "main:app"]
Enter fullscreen mode Exit fullscreen mode

Conclusion

This way of doing things only works if you use a language or framework that is capable of serving static content, and if the WEB interface is coded with a tool that knows how to compile continuously without necessarily having a Web service.

Thus, we eliminate PHP... Because with it, it is necessary, in most cases, to use a Web server (nginx + FPM or Apache).

On the other hand, with Go, Python, Rust, or even Ruby, everything will be fine because these languages are stand-alone.

I have seen, too often, many engineers, both beginners and experienced, launch a bundler in web server mode and struggle with configuration management + CORS in development mode.

The fear of using the API service to provide the pages is still big. Indeed, we can think that it would be good to use a "real" HTTP server to provide the WEB pages later (with a reverse proxy for example)

However, to go fast, for a small project, or simply to propose a first iteration of the application, this method that I propose is more than enough. And it has the merit to be adapted to a reverse-proxy.

Note that it is not forbidden to serve, as I propose in this article, the static pages via the API. In fact, the rule that states that it is imperative to go through a reverse-proxy is a bit outdated, because modern languages and current fameworks are quite good in this exercise.

Don't make me say what I didn't say, an upstream reverse-proxy is of course a good security guarantee. It will allow you to manage the TLS/SSL, the cache, some access rules or the load balancing!

But this reverse-proxy can perfectly call the API to have the static pages, to cache them, instead of reading them directly from the disk. This will also allow you to manage templating or content patching from your API. So, don't forbid yourself to do it.

In short, there is nothing wrong (really, nothing wrong at all) with letting the libraries provide the static pages, if only for a while.

Top comments (2)

Collapse
 
tbroyer profile image
Thomas Broyer

You can have the bundler dev server proxy to the API server, but I agree using watch mode is the way to go; if only to get the server intercept requests to the HTML page and handle authentication through redirections (to an SSO or simply a login form).

This is the way to go… unless your building a PWA in which case the dev server approach reverse-proxying to the API should work just OK too (at one point, you'll necessarily have your HTML loaded from your service worker and your authentication with the server will have expired one way or another)

Collapse
 
helloooojoe profile image
Joseph Garza

Work smarter not harder. WebAPI with .NET 7 on the backend and Angular 15 on the frontend.