Jarek Hartman
Saturday, February 7, 2026

Vercel Functions with Go

Intro and Summary

This is my hands‑on exploration of Vercel Serverless Functions for deploying small Go web APIs. After a bit of reshaping the folder structure (especially when I started pulling code into packages), it turned out to be straightforward and surprisingly clean.

To set the tone, here’s the Vercel logo I used in this project:

Vercel logo

The working structure for me looks like this:

../vercel-functions
|-- README.md
|-- api
|   |-- date.go
|   `-- hello.go
|-- cmd
|   `-- main.go
|-- go.mod
|-- pkg
|   `-- db
|       `-- db.go
|-- public
`   `-- index.html

Links:

Local Development of the API Functions

I always start locally before I deploy. The goal is simple: create a function under /api, wire it to a local server, and check the response.

$ mkdir vercel-functions
$ go mod init vercel-functions && go mod tidy
$ mkdir api cmd

Vercel’s docs say the Go runtime compiles Go files inside /api, expecting a single exported HTTP handler per file. That file name becomes the function name in the URL.

First handler

api/handler.go
package api

import "net/http"

func HttpHandler(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Hello!"))
}

Local runner (plain net/http is enough):

cmd/main.go
package main

import (
	"log"
	"net/http"
	"vercel-functions/api"
)

func main() {
	http.HandleFunc("GET /api", api.HttpHandler)

	log.Println("Starting listener... :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

So the minimal layout becomes:

../vercel-functions
|-- README.md
|-- api
|   `-- handler.go
|-- cmd
|   `-- main.go
`-- go.mod

Run locally:

% go run ./cmd/main.go
2026/02/07 15:34:59 Starting listener... :8080

Test:

% curl http://localhost:8080/api
Hello!%

Committing to GitHub

Next step: push to GitHub so Vercel can build from it.

% git commit -am "Initial commit"
% git push

My repo is here: vercel-functions.

Creating the Vercel Project

Once you import the repo, the default settings usually just work.


Vercel import screen

I left Build Command, Output Directory, and Install Command as defaults. The build completed successfully:

Vercel build succeeded

The key detail: the function URL is derived from the Go filename. A file api/handler.go becomes /api/handler.

curl https://vercel-functions-ochre.vercel.app/api/handler
Hello!%

That answered my next question: how do I define multiple APIs? One file per handler.

Multiple APIs

I added a second handler in api/date.go:

api/date.go
package api

import "net/http"

func HttpDateHandler(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Date!"))
}

Then I renamed handler.go to hello.go and updated my local cmd/main.go router:

- http.HandleFunc("GET /api", api.HttpHandler)
+ http.HandleFunc("GET /api/hello", api.HttpHelloHandler)
+ http.HandleFunc("GET /api/date", api.HttpDateHandler)

To avoid the default placeholder page, I added a very small landing page in public/index.html:

Simple landing page
public/index.html
<!DOCTYPE html>
<body>
    <p>Vercel functions:</p>
    <ul
        class="nav justify-content-center  " >
        <li class="nav-item">
            <a class="nav-link" href="/api/hello">Hello</a>
        </li>
        <li class="nav-item">
            <a class="nav-link disabled" href="/api/date">Date</a>
        </li>
    </ul>
</body>
</html>

Now the repo looks like this:

../vercel-functions
|-- README.md
|-- api
|   |-- date.go
|   `-- hello.go
|-- cmd
|   `-- main.go
|-- go.mod
`-- public
    `-- index.html

After deploy, the public URL shows a clean page and the functions respond as expected:

Vercel landing page

The “Internal Package” Head‑Scratcher

After adding a dummy internal/db/db.go with a function and struct:

internal/db/db.go
package db

type Database struct {
	Name string
}

func NewDB() Database {
	return Database{Name: "I'm your database!"}
}

As expected, this is running locally well:

% curl http://localhost:8080/api/date
Hello from the DB: I'm your database!%

Let’s commit, push and deploy git add . && git commit -am "Added a module" && git push.

I ran into a surprising error while trying to add an internal/db package:

use of internal package vercel-functions/internal/db not allowed

The root cause is a Go rule: internal/ packages can only be imported within the same module tree. Vercel’s Go builder rewrites the module path during build, so your internal package suddenly looks like an external import and gets rejected.

The workaround that failed

I tried moving internal under api/:

../vercel-functions
|-- api
|   |-- date.go
|   |-- hello.go
|   `-- internal
|       `-- db
|           `-- db.go

Vercel then attempted to analyze api/internal/db/db.go as if it were a function file and failed with:

Could not find an exported function in "api/internal/db/db.go"

Try‑and‑Error: Excluded Folders

Where this error came from? I checked Vercel’s build tooling (their Go analyzer) and saw it explicitly ignores these folders:

  • vendor
  • testdata
  • .now
  • .vercel

vendor/ is off‑limits for our own code, and testdata/ felt wrong. .vercel looked promising.

Vercel source code analyses (details here)

Vercel is kind enough to publish their toolset in Github:

  if (!analyzed) {
    const err = new Error(
      `Could not find an exported function in "${entrypoint}"
Learn more: https://vercel.com/docs/functions/serverless-functions/runtimes/go
      `
    );

Which was an output from invocation of analyze tool:

  try {
    debug(`Analyzing entrypoint ${entrypoint} with modulePath ${modulePath}`);
    const args = [`-modpath=${modulePath}`, join(workPath, entrypoint)];
    analyzed = await execa.stdout(bin, args);
  } catch (err) {
    console.error(`Failed to parse AST for "${entrypoint}"`);
    throw err;
  }

Ok, let’s move to analyze.go:

if len(os.Args) != 3 {
	// Args should have the program name on `0`
	// and the file name on `1`
	fmt.Println("Wrong number of args; Usage is:\n  ./go-analyze -modpath=module-path file_name.go")
	os.Exit(1)
}
...
...
if fn.Name.IsExported() == true {
	for _, param := range fn.Type.Params.List {
		paramStr := fmt.Sprintf("%s", param.Type)
		if strings.Contains(string(paramStr), "http ResponseWriter") && len(fn.Type.Params.List) == 2 && (fn.Recv == nil || len(fn.Recv.List) == 0) {
			analyzed := analyze{
				PackageName: parsed.Name.Name,
				FuncName:    fn.Name.Name,
			}
			analyzedJSON, _ := json.Marshal(analyzed)
			fmt.Print(string(analyzedJSON))
			os.Exit(0)
		}
	}
}

Nothing really special, except this init function:

func init() {
	ignoredFolders := []string{"vendor", "testdata", ".now", ".vercel"}

	// Build the regex that matches if a path contains the respective ignored folder
	// The pattern will look like: (.*/)?vendor/.*, which matches every path that contains a vendor folder
	for _, folder := range ignoredFolders {
		ignoredFoldersRegex = append(ignoredFoldersRegex, regexp.MustCompile("(.*/)?"+folder+"/.*"))
	}
}

…and how it’s being used:

func isInIgnoredFolder(path string) bool {
	// Make sure the regex works for Windows paths
	path = filepath.ToSlash(path)

	for _, pattern := range ignoredFoldersRegex {
		if pattern.MatchString(path) {
			return true
		}
	}
	return false
}

So I moved the internal package under api/.vercel/ and updated imports:

- vercel-functions/api/internal/db
+ vercel-functions/api/.vercel/db

That worked. Deploy succeeded:

Successful deploy

It’s not pretty, and it relies on an implementation detail that might change, but it unblocked me.

Vercel Configuration via vercel.json

I tried to make this cleaner with vercel.json using includeFiles/excludeFiles, but I couldn’t get it to work for Go builds. Here’s what I tried:

vercel.json
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "functions": {
    "api/*": {
      "excludeFiles": "api/internal/**/*"
    },
    "api/internal/db/*": {
      "excludeFiles": "api/internal/db/*"
    }
  }
}

Maybe I missed a detail, but I couldn’t make the Go runtime honor those rules in my test.

At last - elegant solution

How about tryinig something obvious… As I wrote earlier:

Vercel’s Go builder rewrites the module name during build. The builder temporarily changes your go.mod module to the handler package name, so any import of vercel-functions/internal/db becomes an external module import — and Go blocks internal packages across module boundaries.

Why then not move pkg/db/db.go?!

../vercel-functions
|-- README.md
|-- api
|   |-- date.go
|   `-- hello.go
|-- cmd
|   `-- main.go
|-- go.mod
|-- pkg
|   `-- db
|       `-- db.go
|-- public
|   `-- index.html
`-- vercel.json.bak

and changed date.go file:

diff --git a/api/date.go b/api/date.go
index 415e6b2..567139b 100644
--- a/api/date.go
+++ b/api/date.go
@@ -3,7 +3,7 @@ package api
 import (
        "fmt"
        "net/http"
-       "vercel-functions/api/internal/db"
+       "vercel-functions/pkg/db"
 )

Commit, push and deploy git add . && git commit -am "pkg/db now".

Yeah 💪

Image

Closing Thoughts

Vercel’s Go runtime is refreshingly simple when you stay inside /api and keep handlers small and independent. The moment you want shared internal packages, you have to understand how their build pipeline works. For quick prototypes and tiny APIs, it’s great. For larger Go codebases, I’d be careful and test the packaging strategy early.

References