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.Handlerwhich combines static and dynamic paths

    • dataprovider.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 ”, 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 it in the right place in the template.

The variable is declared using the `` tag, with required attributes 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 GetRouteRootDataone 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">&lt;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:

  1. ssr:for="value in array"

  2. 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 /homefor 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 pagesnamely add a tag <ssr:content> and so that even when entering the address http://localhost:8080/ subhandler was executed /homeyou 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*DefaultSubRouteWhere *– handler's name.

Same as page /home can you make a page /contactssimply 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.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 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 developmentbut you can pass a parameter -prod V go-ssrand 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.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *