
How we Integrated a React App into a GO CLI
Embedding a React app into a GO CLI is a complex process that took our team close to three to four weeks to solve. Here is how our team solved it.
Embedding a React app into a GO CLI is an uncommon product decision, so before diving into the technicalities it’s worth calling out why our team made this decision.
If you are unfamiliar with Plural, it is an open-source DevOps platform that simplifies deploying open-source software on Kubernetes. Our platform is available via a cloud shell experience or through downloading our CLI experience.
While we recommend using our cloud shell experience since it comes with all the tools and dependencies needed to run Plural, users still prefer to use their own CLI for an extra added layer of security.
Previously, deploying Plural via our CLI or cloud shell was a suboptimal experience for engineers. It required a ton of re-typing, and users could not go back and update values. As a result, around 30 - 35% of users were receiving error messages during onboarding.
We knew that we needed to improve the onboarding process to increase the user success rate. At the start of the year, we completely redesigned our cloud-shell experience with the hopes of reducing error rates. A big part of this redesign was implementing an install wizard in our cloud shell. That alone led to a drop in users hitting errors in our cloud shell experience and we’re now consistently around a 90% success rate for onboarding users.
However, users still faced similar issues with our CLI deployments, the most secure and robust way to use Plural. Making this experience as seamless as good as possible was a high priority for our team. Earlier in the year, we saw a drop in user error rates when we implemented an install wizard in our cloud shell. To stay consistent in our product experience and to reduce error rates with users onboarding through our CLI experience, we implemented the install wizard in our CLI.
The only problem was embedding a React app into a GO CLI is a complex process that took our team close to three to four weeks to solve. Here is how our team solved it.
Identifying a Framework for Embedding Our UI
Before identifying a framework, I had a few requirements in mind that our solution had to contain.
- The framework had to be available within Go so I could reuse our current code base which is written in Go. The main reason for this was so I could call some GO functions directly in the front end to avoid rewriting all this logic.
- I needed to use React so I could reuse our design system for creating this UI in the CLI.
Early on in my research, I came across Electron and Tauri.
I had previous experience working with Electron and it is a popular solution for embedding Chromium and Node.js to create desktop applications. However, there are some major downsides to it such as it embeds the whole chromium inside making our binary file way too big which is what we were looking to avoid.
Tauri’s framework was interesting and has a ton of potential. While it’s similar to the one that we ended up choosing, it was written in Rust and didn’t allow me to reuse our current code base.
Ultimately I landed on using the Wails framework and it has been a flexible solution for our team so far. Wails is a project that enables you to write desktop apps using Go and web technologies. A big reason for choosing Wails was that it automatically makes Go methods available to JavaScript, allowing us to call them by name from our front end.

Once I selected our framework, it was time to implement it. But, this did come with a few unexpected challenges that I had to quickly overcome.
Challenge 1: Re-Configuring our GitHub Deployment Pipeline
Since Plural does not provide the UI for all architectures (we only support a handful of architectures), I had to build a Go build pipeline and rework from scratch our GitHub deployment pipeline that we also use for our CLI.
To achieve this I had three parallel builds at the same time using Windows, Linux, and MacOS host machines thanks to the GoReleaser pro "split" feature.
The first job matrix runs all jobs in parallel at the same time on three separate hosts OS (Windows, Linux, Darwin) and builds the result binaries. It is then picked up by the next step (release) and it takes care of uploading artifacts to our GitHub release and updating our upstream brew artifact. The last part takes care of uploading dockerized images w/ CLI that we can further use (i.e. in the Cloud Shell in the Plural app.)
The below code uses GoReleaser pro split build feature and our conditional tag-based Go build pipeline. Depending on the architecture it will either build a CLI binary with our without embedded.
# Requires a GoReleaser Pro to run
partial:
by: goos
before:
hooks:
- go mod tidy
builds:
- id: plural-cli
targets:
- linux_amd64
- linux_arm64
- windows_amd64
- windows_arm64
- darwin_amd64
- darwin_arm64
env:
- CGO_ENABLED=1
ldflags:
- -s
- -w
- -X "github.com/pluralsh/plural/cmd/plural.Version={{.Version}}"
- -X "github.com/pluralsh/plural/cmd/plural.Commit={{.Commit}}"
- -X "github.com/pluralsh/plural/cmd/plural.Date={{.Date}}"
- -X "github.com/pluralsh/plural/pkg/scm.GitlabClientSecret={{.Env.GITLAB_CLIENT_SECRET}}"
tags:
- desktop
- production
- ui
binary: plural
# Do not embed UI into linux/arm64 and windows/arm64 binaries
overrides:
- goos: linux
goarch: arm64
tags: [linux]
env:
- CGO_ENABLED=0
- goos: windows
goarch: arm64
tags: [windows]
env:
- CGO_ENABLED=0
The above code It is then utilized by the modified GitHub action.
name: CD / CLI
on:
push:
tags:
- 'v*.*.*'
jobs:
# Build binaries with GoReleaser
prepare:
strategy:
matrix:
os: [ ubuntu-latest, macos-latest, windows-latest ]
include:
- os: ubuntu-latest
goos: linux
- os: macos-latest
goos: darwin
- os: windows-latest
goos: windows
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v3
with:
go-version: 1.19
cache: true
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 16.18.1
- name: Setup SHA variable
shell: bash
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Setup Cache
uses: actions/cache@v3.2.3
with:
path: dist/${{ matrix.goos }}
key: ${{ matrix.goos }}-${{ env.sha_short }}
enableCrossOsArchive: true
- name: Install Dependencies
if: matrix.goos == 'linux'
shell: bash
run: sudo apt install -y libwebkit2gtk-4.0-dev libgtk-3-dev
- name: Build web
shell: bash
run: make build-web
- name: GoReleaser (Build)
uses: goreleaser/goreleaser-action@v4
with:
distribution: goreleaser-pro
version: latest
args: release --clean --split
env:
CGO_LDFLAGS: "${{ matrix.goos == 'darwin' && '-framework UniformTypeIdentifiers' || '' }}"
GOOS: ${{ matrix.GOOS }}
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLAB_CLIENT_SECRET: ${{ secrets.GITLAB_CLIENT_SECRET }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
# Release binaries with GoReleaser
release:
runs-on: ubuntu-latest
needs: prepare
env:
DOCKER_CLI_EXPERIMENTAL: "enabled"
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: actions/setup-go@v3
with:
go-version: 1.19
cache: true
- name: Copy Cache From Previous Job
shell: bash
run: |
echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: Restore Linux Cache
uses: actions/cache@v3.2.3
with:
path: dist/linux
key: linux-${{ env.sha_short }}
- name: Restore Darwin Cache
uses: actions/cache@v3.2.3
with:
path: dist/darwin
key: darwin-${{ env.sha_short }}
- name: Restore Windows Cache
uses: actions/cache@v3.2.3
with:
path: dist/windows
key: windows-${{ env.sha_short }}
enableCrossOsArchive: true
- name: GoReleaser (Release)
uses: goreleaser/goreleaser-action@v4
if: steps.cache.outputs.cache-hit != 'true' # do not run if cache hit
with:
distribution: goreleaser-pro
version: latest
args: continue --merge
env:
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITLAB_CLIENT_SECRET: ${{ secrets.GITLAB_CLIENT_SECRET }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
Challenge 2: Configuring and Running the UI After typing Plural Install
After a user runs Plural Install in the CLI, the UI opens up to allow the user to select the applications they want to install. By default, Wales recommends building a standard binary that when executed opens the UI. In reality, it’s not destined to work directly with CLIs.
To solve for this I ended up creating a custom Golang pipeline using build tags to include the UI when we only type in Plural Install.
To conditionally embed the ‘install’ command into the CL during compilation we pass `-tags ui` or `-tags generate` to the `go build` command. Once I had this set up I then added this code to embed the code that actually handles the UI conditionally. This code block below simply opens the actual UI windows when you call `plural install`. It uses build constraints (tags) to only include this code when we need it.
//go:build ui || generate
package plural
import (
"github.com/urfave/cli"
"github.com/pluralsh/plural/pkg/manifest"
"github.com/pluralsh/plural/pkg/ui"
"github.com/pluralsh/plural/pkg/wkspace"
)
func (p *Plural) uiCommands() cli.Command {
return cli.Command{
Name: "install",
Usage: "opens installer UI that simplifies application configuration",
Action: tracked(rooted(p.run), "cli.install"),
}
}
func (p *Plural) run(c *cli.Context) error {
_, err := wkspace.Preflight()
if err != nil {
return err
}
_, err = manifest.FetchProject()
if err != nil {
return err
}
_, err = manifest.FetchContext()
if err != nil {
return err
}
p.InitPluralClient()
return ui.Run(p.Client, c)
}
I then ensured that the tags mentioned above are not provided during the build process. If they aren't, this code will be embedded so that the build will not fail.
Next, I had to conditionally embed code that runs the UI in order to generate bindings that allow us to call Go methods in the UI to generate bindings that allow calling Go methods in the UI. The below code generates frontend bindings used to only call the backend when a ‘generate’ tag is provided during the build process.
//go:build generate
package ui
import (
"log"
)
// Used to generate frontend bindings used to call backend.
// Only needed when 'generate' tag is provided during the build.
func init() {
err := Run(nil, nil)
if err != nil {
log.Fatal(err)
}
}
Challenge 3: Refactoring our GO project
Early on one thing that I overlooked was how much we were going to need to refactor how our team set up our Go project. Basically, we had to extract our application entry point in our main package from the `cmd/plural` directory to the root of the project since Wails currently does not play well with different setups. To do this it required both code refactoring and updating all our build CI/CD pipelines to work smoothly with the new code layout.
Along the way, there ended up being a good amount of smaller problems that we needed to quickly solve such as running our CI/CD. Previously, we simply would use `go build` on any host for cross-building our CLI. On the other hand, Wails required the 'CGO_ENABLED=1' flag when building the application and that forced us to actually run the build for target architecture on the target host OS.
Thankfully, GitHub Actions allow us to do just that and run the build natively on every OS. There are still ways to avoid that but when I looked into them, they were much more complicated than the solution I have decided to go with.
Another problem that I encountered was how to call Go functions from the front-end code base. This problem ended up being easier to solve than I anticipated, and I followed Wails official guidance for calling Go function bindings. To simplify this for us internally I ended up adding some wrapper code on top of what they recommended.
Moving Forward
Overall, I enjoyed working on this complex project. If you are looking to solve a similar problem I advise that you try not to overcomplicate everything.
Unfortunately, for us, our problem was too complex and we could not follow Wails recommendations because it involved being quite familiar with Golang and using Golang build constraints. A majority of use cases will likely not be as complex as ours and you should be able to follow along with their documentation and be good to go.
I hope you can learn a bit from this, and please do not hesitate to reach out if you have any additional questions about how we solved this as an engineering team.
To learn more about how Plural works and how we are helping engineering teams across the world deploy open-source applications in a cloud production environment, reach out to our team for more information.
Ready to effortlessly deploy and operate open-source applications in minutes? Get started with Plural today.
Join us on our Discord channel for questions, discussions, and to meet the rest of the community.
Newsletter
Be the first to know when we drop something new.