Server Side Rendering on Go

Server Side Rendering on Go

Life is an eternal spiral, where everything goes in a circle, but with each turn it gets better. As recently as 20 years ago, I was writing web applications in Perl + Template Toolkit 2, generating server-side HTML. As time went on, web development split into two halves: frontend and backend, with APIs in between. I eventually switched from Perl to Go for the backend and AngularJS and then Vue for the frontend. In such a stack, I created several projects, including HighLoad.Fun. It was convenient to write an API and generate a client library in TypeScript, and the Vue application was deployed as a SPA. Everything seemed to be going well… until I needed to implement SSR for SEO. This is where the problems started: it was necessary to set up a NodeJS server to execute SSR, which should go to the Go server for data, think about where the code is currently being executed, on the server or in the browser, and write and write silly code that translates 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 on Go is generally not a problem: you can use ready-made templates, manually write controllers, and configure WebPack to compile statics. But all this is long and inconvenient. And most importantly, I love writing programs, but I hate writing code. And then I set myself a goal: to create a tool that would make my life easier and automatically solve most of the tasks for me.

I needed a generator that would:

  • Converted Vue-like templates to Go code with typed variables, allowing compile-time error catching.

  • Automatically generated DataProvider interfaces for receiving data and, preferably, their basic implementation.

  • Collected and connected only the necessary JS and CSS files from the TypeScript and SCSS files lying next to the templates.

  • Supported variables, expressions, conditions and loops on templates like Vue.

  • Combined templates from subfolders according to the Vue-tag principle .

  • Automatically routed pages, supporting dynamic parameters.

And most importantly, all this should work automatically: changes in the source code are automatically reassembled and restarted without any extra effort.

After a series of experiments and several nights, I seem to have succeeded. Below is a detailed tutorial on how to develop fast and user-friendly sites with GoSSR.

We create a project

The generator requires Go version 1.22 and above and NPM, both of which must be available in the 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 been successfully completed, it is necessary to initialize the GoSSR project in an empty folder:

go-ssr -init -pkg-name ssrdemo

where ssrdemo – The name of the Go package used for the application, you can choose any valid one. The generator will create main files and folders, download Go’shny and frontend dependencies. The 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.Handlerwhich combines static and dynamic paths

    • dataprovider.go: merges 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 location where the template data is prepared

      • ssrroute_gen.go: implementation of the template, converted to it index.html

      • ssrhandler_gen.go: the handler that combines all child handlers is contained only in the pages folder, it will not be in subfolders.

      • index.html: template

      • index.ts: scripts for the page, optional file

      • styles.scss: styles for the page, optional file

    • static/: this is where the collected statics are compiled

In fact, it’s a ready-made one-page project that you can run by executing

# go run .

Information will appear on the screen that the server is available at http://localhost:8080/. If you open it in a browser, it will look like this:

In general, it is not necessary to start the generator and assemble the project by hand every time, it is enough to do it

go-ssr -watch

and everything will happen automatically as soon as the source codes change. Moreover, only the necessary parts will be reassembled.

Templates

Variables and expressions

Suppose that the task is to display not just “Hello world”, but “Hello”, for this in the file 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 them in the right place in the template.

A variable is declared using the ` tag`, with mandatory attributes name and type. And in order to use it in the template, it is necessary to enclose it in double brackets {{ varName }}. The result is a file index.html should look like this (see lines 10 and 11):




    
    
    GoSSR
    





After saving the file, GoSSR will automatically regenerate the template, in which the new variable will appear.

The variable in the template was declared and used, now it is necessary to put data in it, for this in the file internal/web/pages/dataprovider.go you need to change the method GetRouteRootDataone of its arguments is a pointer to a structure whose fields are template variables. The 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
}

Save the file, GoSSR will reassemble and restart the program, and you can refresh the page in the browser, word world expectedly gone, but if you add a parameter name=User on request, then everything will be as intended:

If you pass something potentially harmful to the parameter, nothing terrible will happen, the output is shielded:

If you need to insert unshielded HTML, then instead {{ }} need to use {{$ expr }}but be extremely careful. If the previous example changes the usage {{$ }}Then the result will be deplorable:

If you do not pass a parameter with a name, then in order to avoid a broken phrase, you can either add a default value in the DataProvider, or you can use the power of the expressions of the templater, and there is also a ternary if:

Для объявления строк в шаблоне можно использовать как одинарные, так и двойные кавычки.

Условия

Для условного отображения HTML тегов можно использовать атрибуты:

Для примера можно добавить ещё одну переменную получаемую из параметров, пусть будет age. For her, we will conclude the age group:


<18

18-30

31-60

61+

In the DataProvider, you need to add number retrieval and parsing:

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
}

We save the files and update the page:

Cycles

Similar to conventions, loops can be used in HTML tags. There are 2 options for this:

  1. ssr:for="value in array"

  2. ssr:for="index, value in array"

As an example, let's add a list of lines to the current page:


Accordingly, in the DataProvider, 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 site is not a single page, but a whole hierarchy. GoSSR makes it easy to manage. The template is in the folder pages is a binding, it defines the appearance of the site, header, menu, ..., and the sections of the site are created in subfolders. For example, let's create a page /homefor this in the folder pages you need to create a subfolder with this 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.

In the file index.html I suggest putting the following content:

Теперь нужно немного модифицировать шаблон лежащий в папке pagesnamely add a tag And so that even when visiting the address http://localhost:8080/ the subhandler is executed /homeyou need to add the `default="home"> attribute, the result should look like this:








If you open the page at http://localhost:8080/, it will redirect to http://localhost:8080/home and the content of the template 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 the view method GetRoute*DefaultSubRoutewhere *- Name of the handler.

Similarly to the page /home you can make a page /contactsby simply adding another folder in which there is index.html:

Теперь доступен и URL http://localhost:8080/contacts:

Вложенность путей и шаблонов может быть любой и каждый шаблон может содержать свой набор переменных.

Переменные в URL

GoSSR поддерживает переменные внутри пути, например можно создать страницы, которые будут содержать в пути логин пользователя, т.е. http://localhost/login123/info, где login123 dynamic string. To do this, you need to create a folder that starts and ends with _for example _userId_. Now all non-existent paths at this level will fall into this handler, and the value can be retrieved from the DataProvider using the r.URLParam("userId"). Below is an example of how it looks in the project.

File pages/_userId_/index.html:


User ID: {{ userId }}

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

To check the functionality, save the files and go to http://localhost:8080/login123, the result should be as follows:

The username from the URL appeared on the page. And if you go to http://localhost:8080/home, the template in the folder will work pages/home/ as intended.

Statics

You can put scripts, styles and pictures next to each template. They will be bundled using WebPack, for GoSSR I wrote a special plugin GoSSRAssetsPlugin, it is already used in boilerplate configuration.

For each template, its own bundle is created, which will be connected only when this template is drawn. And in the right order. To specify the place where statics are imported, you need to add a tag in the main template . Usually it should be put before closing .

Скрипты

Входной точкой является файл index.tsother files if they are not imported into index.tswill 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 the following content:

pages/index.ts(Already exists, just 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 rearranged and go to the page http://localhost:8080/home, in the output you can see the connected JS files in the nesting order of the templates:

And if you look in the console, then there are expected to be 2 messages:

If you go to the page with contacts, there will be 2 messages:

Styles

Similar to scripts, styles can be placed next to templates, which will be connected only when a specific template is displayed. To do this, you need to create a file styles.scss.

For example, I will show how to connect Bootstrap. First of all, it is necessary in the file internal/web/package.json in the section dependencies add dependency on "bootstrap": "^5.3.3". Save the file, GoSSR will automatically download all necessary dependencies. Once that 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 statics to be reassembled and go to the address http://localhost:8080/home, Bootstrap is connected and its styles are applied:

Image

You can put pictures next to the template, and how src use a relative path to it, for example:

GoSSR will copy it to the folder static/and the address is src will replace it with a valid one.

For example, I suggest you take an image of the Gosh mascot, save it in a folder pages/home/ under the name gopher.png. Then we will add it to the file pages/home/index.html:



After the page is automatically rearranged and updated, you will get:

The image is loaded from the folder /static/.

Assembly modes

By default, WebPack is called in mode developmentbut you can pass a parameter -prod in go-ssrand then WebPack will be called with `--mode production`, resulting in more compact bundles.

Conclusion

This is the first public version with bugs. But as an idea that can be developed, it seems to me quite.

A more comprehensive example can be found on GitHub in the example folder. There is also a benchmark, which on my laptop gives:

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.

Related posts