
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:
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.htmlLinks:
- GitHub repo: vercel-functions
- Demo: vercel-functions-ochre.vercel.app
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 cmdVercel’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
package api
import "net/http"
func HttpHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello!"))
}Local runner (plain net/http is enough):
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.modRun locally:
% go run ./cmd/main.go
2026/02/07 15:34:59 Starting listener... :8080Test:
% 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 pushMy repo is here: vercel-functions.
Creating the Vercel Project
Once you import the repo, the default settings usually just work.

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

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:
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:

<!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.htmlAfter deploy, the public URL shows a clean page and the functions respond as expected:

The “Internal Package” Head‑Scratcher
After adding a dummy internal/db/db.go with a function and struct:
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 allowedThe 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.goVercel 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:
vendortestdata.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:

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:
{
"$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/dbbecomes 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.bakand 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 💪

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
- vercel-functions repo — My working example.
- Live demo — Deployed functions and landing page.
- Vercel Go runtime docs — How Vercel discovers and builds Go functions under
/api. - Vercel project configuration — The
vercel.jsonincludeFiles/excludeFilesrules. - Vercel build tooling (go-helpers.ts) — The error message and analyzer entrypoint.
- Vercel Go analyzer — The analyzer logic and ignored folders.