Server Side Rendering in Go
Life is an eternal spiral, where everything goes in a circle, but with each turn it gets better. Even 20 years ago, I wrote web applications in Perl + Template Toolkit 2, generating HTML on the server side. As time went on, web development was divided into two halves: front-end and back-end, with an API in between. Over time, I switched from Perl to Go for the backend and AngularJS, and then Vue for the frontend. I created several projects in this stack, including HighLoad.Fun. It was convenient to write an API and generate a client library in TypeScript, and the Vue application was deployed as an SPA. Everything seemed to be going well… until the need came to implement SSR for SEO. This is where the problems started: it was necessary to set up a NodeJS server to perform SSR, which should go to the Go server for data, think about where the code is currently running, on the server or in the browser, and write and write meaningless code that transfers the data.
Then I was faced with a choice: either abandon Go on the backend, or abandon Vue on the frontend. For me, the choice was obvious: I stayed with Go.
Generating HTML in Go is, in general, not a problem: you can use ready-made template engines, manually write controllers, and configure WebPack to build static content. But all this is long and inconvenient. And most importantly, I love writing programs, but I hate writing code. And then I set a goal: to create a tool that would make my life easier and would automatically solve most of the problems for me.
I needed a generator that would:
Turned Vue-like templates into Go code with typed variables, allowing you to catch errors at the compilation stage.
Automatically generated DataProvider interfaces for receiving data and, preferably, their basic implementation.
Collected and included only the necessary JS and CSS files from the TypeScript and SCSS files lying next to the templates.
Supported variables, expressions, conditions and loops in templates like Vue.
Combined templates from subfolders using the Vue tag principle
<router-view/>
.Automatically routed pages, supporting dynamic parameters.
And most importantly, all this should work automatically: changes in the source code are automatically rebuilt and restarted without any extra effort.
After a series of experiments and several nights, I think I succeeded. Below the cut is a detailed tutorial on how to develop fast and convenient websites using GoSSR.
Creating a project
The generator requires Go version 1.22 or higher and NPM, both of which should be available in PATH
.
When the environment is configured, you need to install the GoSSR generator:
go install github.com/sergei-svistunov/go-ssr@latest
After the installation has completed successfully, you need to initialize the GoSSR project in an empty folder:
go-ssr -init -pkg-name ssrdemo
Where ssrdemo
– you can choose any valid name of the Go package used for the application. The generator will create the main files and folders, download Go's and front-end dependencies. The end result will be something like this:
├── go.mod
├── gossr.yaml
├── go.sum
├── internal
│ └── web
│ ├── dataprovider.go
│ ├── node_modules
│ │ └── ...
│ ├── package.json
│ ├── package-lock.json
│ ├── pages
│ │ ├── dataprovider.go
│ │ ├── index.html
│ │ ├── index.ts
│ │ ├── ssrhandler_gen.go
│ │ ├── ssrroute_gen.go
│ │ └── styles.scss
│ ├── static
│ │ ├── css
│ │ │ └── main.49b4b5dc8e2f7fc5c396.css
│ │ └── js
│ │ └── main.49b4b5dc8e2f7fc5c396.js
│ ├── tsconfig.json
│ ├── web.go
│ ├── webpack-assets.json
│ └── webpack.config.js
└── main.go
Main files and folders:
main.go: web application
gossr.yaml: config for GoSSR
internal/web/: the folder where all the magic happens:
web.go: contains
http.Handler
which combines static and dynamic pathsdataprovider.go: combines all child dataproviders for use in SSRHandler
package.json: all frontend dependencies
webpack.config.js: here you can customize the frontend assembly
pages/: root folder for pages, all paths are built from it
dataprovider.go: the place where the data for the template is prepared
ssrroute_gen.go: implementation of the template, turns into it
index.html
ssrhandler_gen.go: the handler that combines all child handlers is contained only in the pages folder; it will not be found in subfolders.
index.html: sample
index.ts: scripts for the page, optional file
styles.scss: styles for the page, optional file
static/: collected static is added here
In fact, this is a ready-made one-page project that can be launched by running
# go run .
The screen will display information that the server is available at http://localhost:8080/. If you open it in a browser, it will look like this:
In general, you don’t need to run the generator and build the project manually every time, it’s enough to run
go-ssr -watch
and everything will happen automatically as soon as the sources change. Moreover, only the necessary parts will be reassembled.
Templates
Variables and Expressions
Let’s assume that the task is to display not just “Hello world”, but “Hello internal/web/pages/index.html
you need to declare a variable (Go is a statically typed language, so you need to know the type) and insert it in the right place in the template.
The variable is declared using the `name
And type
. And in order to use it in a template, you need to enclose it in double brackets {{ varName }}
. As a result, the file index.html
should look like this (see lines 10 and 11):
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GoSSR</title>
<ssr:assets/>
</head>
<body>
<ssr:var name="userName" type="string"/>
<h1>Hello {{ userName }} </h1>
</body>
</html>
After saving the file, GoSSR will automatically regenerate the template with a new variable.
The variable in the template was declared and used, now you need to put data in it, for this in the file internal/web/pages/dataprovider.go
need to change method GetRouteRootData
one of its arguments is a pointer to a structure whose fields are template variables. The end result should be something like this:
func (p *DPRoot) GetRouteRootData(ctx context.Context, r *mux.Request, w mux.ResponseWriter, data *RouteData) error {
// Берём данные из параметра name
data.UserName = r.FormValue("name")
return nil
}
We save the file, GoSSR will rebuild and restart the application, and you can refresh the page in the browser, word world
expectedly disappeared, but if you add the parameter name=User
into the request, then everything will be as planned:
If you pass something potentially malicious into the parameter, then nothing bad will happen, the output is escaped:
If you really need to insert unescaped HTML, then instead {{ }}
need to use {{$ expr }}
but be extremely careful. If the previous example is changed to use {{$ }}
then the result will be disastrous:
If you do not pass a parameter with a name, then to avoid a dangling phrase, you can either add a default value in the DataProvider, or you can use the power of template engine expressions, and there is also a ternary if:
<h1>Hello {{ userName == "" ? 'Anonymous' : userName }} </h1>
You can use either single or double quotes to declare strings in a template.
Terms
To conditionally display HTML tags you can use the following attributes:
For example, you can add another variable obtained from the parameters, let it be age
. For her, let's deduce the age group:
<ssr:var name="age" type="uint8"/>
<p ssr:if="age < 18"><18</p>
<p ssr:else-if="age <= 30">18-30</p>
<p ssr:else-if="age <= 60">31-60</p>
<p ssr:else>61+</p>
In the DataProvider you need to add receiving and parsing numbers:
func (p *DPRoot) GetRouteRootData(ctx context.Context, r *mux.Request, w mux.ResponseWriter, data *RouteData) error {
// Берём данные из параметра name
data.UserName = r.FormValue("name")
// Получаем возраст
if ageParam := r.FormValue("age"); ageParam != "" {
age, err := strconv.ParseUint(ageParam, 10, 8)
if err != nil {
return err
}
data.Age = uint8(age)
}
return nil
}
Save the files and refresh the page:
Cycles
Similar to conditions, loops can be used in HTML tags. There are 2 options for this:
ssr:for="value in array"
ssr:for="index, value in array"
As an example, let's add a list of strings to the current page:
<ssr:var name="list" type="[]string"/>
<ul>
<li ssr:for="value in list">{{ value }}</li>
</ul>
In the DataProvider, accordingly, you need to assign a value to the variable:
func (p *DPRoot) GetRouteRootData(ctx context.Context, r *mux.Request, w mux.ResponseWriter, data *RouteData) error {
// ...
// Данные для цикла
data.List = []string{"value1", "value2", "value3"}
return nil
}
As a result we get
Routing
A website is not one page, but a whole hierarchy. GoSSR makes it quite easy to manage. The template is in the folder pages
is a binding, it sets the appearance of the site, header, menu, …, and sections of the site are created in subfolders. For example, let's create a page /home
for this in the folder pages
you need to create a subfolder with that name and create a file in it index.html
. The generator will see the new file and create a DataProvider for it in the file dataprovider.go
And ssrroute_gen.go
with the implementation of the template in Go.
To file index.html
I suggest putting the following content:
<h1>Home page</h1>
Now you need to slightly modify the template in the folder pages
namely add a tag <ssr:content>
and so that even when entering the address http://localhost:8080/ subhandler was executed /home
you need to add the `default=”home”> attribute, in the end it should look like this:
<!doctype html>
<html lang="en">
<!-- ... -->
<body>
<ssr:content default="home"/>
<!-- ... -->
</body>
</html>
If you open the page at http://localhost:8080/then a redirect will occur to http://localhost:8080/home and template contents pages/home/index.html
will be added to the content of the parent template pages/index.html
:
You can specify the default handler not only through the template, but also through the DataProvider using a method like GetRoute*DefaultSubRoute
Where *
– handler's name.
Same as page /home
can you make a page /contacts
simply adding another folder in which there is index.html
:
<h1>Contacts page</h1>
URL is now available http://localhost:8080/contacts:
There can be any nesting of paths and templates, and each template can contain its own set of variables.
Variables in URL
GoSSR supports variables inside the path, for example you can create pages that will contain the user's login in the path, i.e. http://localhost/login123/infoWhere login123
dynamic string. To do this, you need to create a folder starting and ending with _
For example _userId_
. Now all non-existent subpaths at this level will fall into this handler, and the value can be obtained in the DataProvider using the method r.URLParam("userId")
. Below is an example of what it looks like in a project.
File pages/_userId_/index.html
:
<ssr:var name="userId" type="string"/>
<h2><strong>User ID:</strong> {{ userId }}</h2>
And the main method in the file pages/_userId_/dataprovider.go
:
func (p *DP_userId_) GetRoute_userId_Data(ctx context.Context, r *mux.Request, w mux.ResponseWriter, data *RouteData) error {
data.UserId = r.URLParam("userId")
return nil
}
This handler can also contain child handlers with their own content.
To check the functionality, save the files and go to the address http://localhost:8080/login123the result should be like this:
The username from the URL appeared on the page. And if you go to http://localhost:8080/homethen the template in the folder will work pages/home/
as intended.
Statics
Near each template you can put scripts, styles and pictures. They will be collected into bundles using WebPack; I wrote a special plugin for GoSSR GoSSRAssetsPluginin the config from the boilerplate it is already used.
For each template, its own bundle is created, which will be connected only when this template is rendered. And in the right order. To indicate the place where statics are imported, you need to add the tag in the main template <ssr:assets/>
. Usually it needs to be put down before closing </head>
.
Scripts
The entry point is a file index.ts
other files if they are not imported into index.ts
will be ignored.
In the current demo project we have a hierarchy of routes:
I suggest creating a file in each of them index.ts
with this content:
pages/index.ts
(already exists, you just need to replace the content):
console.log("Root template")
pages/home/index.ts
:
console.log("Home template")
pages/contacts/index.ts
:
console.log("Contacts template")
We save the files, wait for the statics to be reassembled and go to the page http://localhost:8080/homein the sources you can see the connected JS files in the order of template nesting:
And if you look at the console, you’ll see 2 messages there:
If you go to the page with contactsthen there will be 2 messages:
Styles
Similar to scripts, next to templates you can put styles that will be connected only when a specific template is rendered. To do this you need to create a file styles.scss
.
For example, I'll show you how to connect Bootstrap. First of all you need in the file internal/web/package.json
in section dependencies
add dependency on "bootstrap": "^5.3.3"
. We save the file, GoSSR will automatically download all the necessary dependencies. Once this happens, you can import it into a file pages/styles.scss
:
@use "bootstrap/scss/bootstrap";
body {
background-color: lightgray;
}
We save the file, wait for the statics to be reassembled and go to the address http://localhost:8080/homeBootstrap is connected and its styles are applied:
Pictures
You can also put pictures next to the template, and src
use a relative path to it, for example:
<img src="https://habr.com/ru/articles/848640/./image.png">
GoSSR will copy it to the folder static/
and the address is in src
will replace it with a valid one.
For example, I suggest taking image Gosh mascot, save it in a folder pages/home/
under the name gopher.png
. Then add it to the file pages/home/index.html
:
<h1>Home page</h1>
<img src="https://habr.com/ru/articles/848640/./gopher.png" alt="Gopher">
After automatic rebuilding and updating the page you will get:
The image is loaded from the folder /static/
.
Build Modes
By default, WebPack is called in mode development
but you can pass a parameter -prod
V go-ssr
and then WebPack will be called with `–mode production`, which will lead to more compact bundles.
Conclusion
This is the first public version, which definitely has some flaws. But as an idea that can be developed, it seems fine to me.
A more complex example can be found at GitHub in a folder example. There is also benchmarkwhich on my laptop displays:
goos: linux
goarch: amd64
pkg: github.com/sergei-svistunov/go-ssr/example/internal/web/pages
cpu: AMD Ryzen 7 5800H with Radeon Graphics
BenchmarkSsrHandlerSimple
BenchmarkSsrHandlerSimple-16 432955 2343 ns/op
BenchmarkSsrHandlerDeep
BenchmarkSsrHandlerDeep-16 164113 7131 ns/op
In fact, there is still room for optimization and I will definitely do it, but even now the result is quite fast.