Creating Google Docs with Go & NextJS
Reading Time • 5 min read
Creating Google Docs with Go & NextJS
Creating Google Docs with Go & NextJS

Over the past couple of weeks, we've been setting up a full-stack project using Go and NextJS. You can optionally read parts one and two, and view the starter code in this GitHub repository.

What we'll be building

Recap

We have this project structure:

The directories frontend and backend each contain a webserver, frontend contains a NextJS server which connects to backend, and backend serves both the initial content of a document as well as any updates that are made to it.

Cloning the starter code

It's the "websocket" branch of the repository linked above:

user@computer:~$ git clone -b websocket https://github.com/webappio/golang-nextjs-example.git
user@computer:~$ cd golang-nextjs-example

Adding a text editor to our frontend

For now we'll keep things simple: A single text field for the name of the document, and another text field for the contents of the document.

We'll just use basic HTML with <textarea> and make the title editable with contentEditable="true"

We'll also use the standard React "controlled input" handling (read more about that in the docs here) in order to send updates about the document from the frontend to the backend and vice-versa.

Edit your index.js file to look like this:

import Head from 'next/head'
import styles from '../styles/Home.module.css'
import {useEffect, useState} from "react";

export async function getServerSideProps() {
    const initialData = await fetch("http://localhost:8000/handler-initial-data").then(x => x.json());
    return {props: {data: initialData}}
}

export default function Home(props) {
    const [data, setData] = useState(props.data);
    const [ws, setWS] = useState(null);
    useEffect(() => {
        const newWS = new WebSocket("ws://localhost:8000/handler")
        newWS.onerror = err => console.error(err);
        newWS.onopen = () => setWS(newWS);
        newWS.onmessage = msg => setData(JSON.parse(msg.data));
    }, [])
    return (
        <div className={styles.container}>
            <Head>
                <title>OSS Docs</title>
                <meta name="description" content="Like Google Docs, but open source!"/>
                <link rel="icon" href="/favicon.ico"/>
            </Head>

            <main className={styles.main}>
                <input
                    className={styles.title}
                    onChange={e => ws.send(JSON.stringify({
                        "title": e.target.value,
                        "body": data.body,
                    }))}
                    value={data.title || "Untitled Document"}
                />

                <textarea
                    className={styles.document}
                    onChange={e => ws.send(JSON.stringify({
                        "title": data.title,
                        "body": e.target.value,
                    }))}
                    value={data.body || ""}
                />
            </main>
        </div>
    )
}

You'll have to add a class anywhere in Home.module.css :

.document {
  border-radius: 10px;
  border: 1px solid #b4b4b4;
  display: flex;
  align-self: stretch;
  flex: 0 1 75vh;
  margin: 16px 8px 8px 8px;
  padding: 8px;
}

Sending the document from the backend to the frontend using our WebSocket

For now, we'll assume there's one big global document that everyone is editing. Define a Document struct with fields body and title , and an instance of that struct as a global variable above the main method in main.go

Next, we'll return that document as the data from both the SSR endpoint (for when the user initially visits the page) as well as the websocket (for when new changes are made)

After these changes, the entire main.go file looks like this:

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"github.com/gobwas/ws"
	"github.com/gobwas/ws/wsutil"
	"log"
	"net/http"
)

type Document struct {
	Title string `json:"title"`
	Body  string `json:"body"`
}

var document = Document{
	Title: "Test document",
	Body:  "Hello world\nhere is a second line",
} //TODO HACK: only one document for now

func main() {
	handler := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
		if req.URL.Path == "/handler-initial-data" {
			var documentBytes bytes.Buffer
			err := json.NewEncoder(&documentBytes).Encode(&document)
			if err != nil {
				log.Println("Error encoding document: ", err)
				return
			}
			rw.Header().Set("Content-Type", "application/json")
			rw.Header().Set("Content-Length", fmt.Sprint(documentBytes.Len()))
			rw.Write(documentBytes.Bytes())
		} else if req.URL.Path == "/handler" {
			conn, _, _, err := ws.UpgradeHTTP(req, rw)
			if err != nil {
				log.Println("Error with WebSocket: ", err)
				rw.WriteHeader(http.StatusMethodNotAllowed)
				return
			}
			go func() {
				defer conn.Close()

				var documentBytes bytes.Buffer
				err := json.NewEncoder(&documentBytes).Encode(&document)
				if err != nil {
					log.Println("Error encoding document: ", err)
					return
				}

				err = wsutil.WriteServerMessage(conn, ws.OpText, documentBytes.Bytes())
				if err != nil {
					log.Println("Error writing WebSocket data: ", err)
					return
				}
			}()
		} else {
			rw.WriteHeader(http.StatusNotFound)
		}
	})

	log.Println("Server is available at http://localhost:8000")
	log.Fatal(http.ListenAndServe(":8000", handler))
}

Once you start the backend with go run main.go in the backend directory, you should see this:

HerpPe, the string "Test document" has been:

  1. Pulled from the global variable in main.go,
  2. Sent through the WebSocket in the /handler endpoint,
  3. Set to the value of data in the useEffect in index.js, and
  4. Rendered by referencing data.title in the React.js code. Whew!

Sending changes from the frontend to the backend

The index.js file above already sends events via the WebSocket to when you change the title or the document via onChange. All that remains to do is to handle those events in the backend.

For that, we'll make another goroutine which listens for updates from the client, uses a mutex to deal with concurrency issues, and a condition variable to notify all of the other clients that the document has changed.

Add a mutex and a condition below the document declaration:

var document = Document{
	Title: "Test document",
	Body:  "Hello world\nhere is a second line",
} //TODO HACK: only one document for now
var documentMutex sync.Mutex
var documentCond = sync.NewCond(&documentMutex)

Modify our goroutine to wait for the the document to change before sending an update:

go func() {
	defer conn.Close()

	for { //send the document to the frontend when it changes
		documentMutex.Lock()
		documentCond.Wait()
		documentMutex.Unlock()

		var documentBytes bytes.Buffer
        ...
    }
    ...
}

And finally add a second goroutine to listen for updates from the client:

go func() {
	for { //the client is asking to change the document
		defer conn.Close()

		data, err := wsutil.ReadClientText(conn)
		if err != nil {
			log.Println("Error encoding document: ", err)
			return
		}

		documentMutex.Lock()
		err = json.Unmarshal(data, &document)
		if err != nil {
			documentMutex.Unlock()
			log.Println("Error unmarshalling document: ", err)
			return
		}
		documentCond.Broadcast()
		documentMutex.Unlock()
	}
}()

If everything is set up properly, you should be able to open two tabs, change the document title in one, and see the changes reflected in the other.

We've shared the state of the document between 2 or more browser tabs with only a few hundred lines of code!

The complete source code for this example is available in the google-docs branch of the GitHub repository here.

Last Updated • Dec 26, 2022