Boilerplate Overview: Go + React + Go-Fit
This boilerplate builds the same extension as Node + React — it lists a company's products — with a Go backend on Gin, and MongoDB and Redis for session storage. It is the only Go template, and the Go counterpart of Node + React + Fit.js: go-fit plays the role Fit.js plays there, providing the server, connections, logging, tracing and health checks.
If you do not have the boilerplate, please go through the steps in the Get Started page.
If FDK CLI is already set up, run fdk ext init --template go-react to download the boilerplate explained on this page.
You will need Go 1.26 or later, Node 20 or later for the frontend, and MongoDB and Redis running locally.
Directory Structure
├── README.md
├── .env # Environment variables (copy from .env.example)
├── .env.example
├── fdk.ext.config.json # DO NOT TOUCH. Managed by FDK CLI
├── go.mod # Module requirements and the two replace directives
├── main.go # Entry point: config, connections, routers, serve
├── internal
│ ├── config
│ │ └── config.go # Reads and validates the environment
│ ├── connections
│ │ └── connections.go # Redis, MongoDB and Sentry through go-fit
│ ├── extension
│ │ ├── extension.go # fdkext.Setup — storage, callbacks, webhooks
│ │ └── proxy.go # Storefront proxy path helpers
│ └── router
│ ├── platform.go # Your Platform API routes
│ ├── application.go # Storefront proxy routes
│ └── error.go # Error middleware
└── frontend # Git submodule: example-extension-react
Backend
The backend uses fdk-extension-go for the extension protocol and go-fit for everything around it.
Dependencies
fdkext— the extension helper library, used through its Gin adapterfdkext/gin.fdkclient— the Platform API client.go-fitfor the server, connections, zap logging, tracing and health.ginfor HTTP routing,godotenvfor .env loading.
Neither fdkext nor fdkclient can be installed with go get: each declares a short module name rather than its repository URL. go.mod names the repository for both, and because a replace only applies to the main module, these have to stay in your go.mod rather than being inherited.
replace fdkext => dev.azure.com/GoFynd/JCPLibraries/_git/fdk-extension-go.git v1.1.0
replace fdkclient => dev.azure.com/GoFynd/JCPLibraries/_git/fdk-client-go.git v1.10.3-0temp-1.beta.1
Auth Credentials
The FDK CLI configures the API key and secret during development when you run fdk ext preview. The credentials are taken from the Partners panel, saved in the fdk.ext.config.json file, and passed to the backend as environment variables.
Find API credentials: Partner panel → Extensions → <your-extension> → Credentials
Configuration
internal/config/config.go reads the environment once at startup and fails on anything required that is missing, so a wrong API key stops the process instead of surfacing as a confusing 401 later.
var missing []string
if cfg.Extension.APIKey == "" {
missing = append(missing, "EXTENSION_API_KEY")
}
if cfg.Extension.APISecret == "" {
missing = append(missing, "EXTENSION_API_SECRET")
}
if len(missing) > 0 {
return nil, fmt.Errorf("missing required environment: %s", strings.Join(missing, ", "))
}
A .env file is loaded first if present, but values already in the environment win — so what FDK CLI injects always beats a stale .env.
Two derived settings are worth knowing, because they are why the same code works locally and in production:
// /api locally, where vite proxies to this server; / in production
func (c *Config) APIPrefix() string {
if c.Local { return "/api" }
return "/"
}
// the vite source tree locally, the built assets in production
func (c *Config) StaticDir() string {
if c.Local { return "frontend" }
return "frontend/public/dist"
}
EXTENSION_SLUG prefixes every session key and is the path a storefront proxy attaches at; SERVICE_NAME identifies the service in logs, traces and metrics. FDK CLI does not rewrite either, so every extension generated from this boilerplate starts with the same values — and two of them sharing one Redis would read each other's sessions.
Environment Variables
Set locally:
IS_LOCAL,SERVER_TYPE,SERVICE_NAME,EXTENSION_SLUG,REDIS_TEMP_EXAMPLE_READ_WRITE,REDIS_TEMP_EXAMPLE_READ_ONLY,MONGO_TEMP_EXAMPLE_READ_WRITE
Production values:
EXTENSION_API_KEY,EXTENSION_API_SECRET,EXTENSION_BASE_URL,EXTENSION_ACCESS_MODE
Avoid:
FRONTEND_PORT,BACKEND_PORT,FP_API_DOMAIN— FDK CLI overrides these during development.
go-fit derives connection names from the variable name: REDIS_TEMP_EXAMPLE_READ_WRITE registers a connection called temp_example. Rename these for your extension and set REDIS_SERVICE and MONGO_SERVICE to match.
Setup FDK
internal/extension/extension.go initialises the extension: session storage, the callbacks, and the webhook subscriptions. Setup reaches the platform to read the extension's registered details, so a wrong key or secret fails here rather than on the first request.
// Two-level storage: Redis answers reads, Mongo survives a cold cache.
store, err := multilevel.New(ctx, multilevel.Options{
Prefix: cfg.Extension.Slug,
Redis: conns.Redis,
Database: conns.Mongo,
})
ext, err := fdkext.Setup(ctx, fdkext.Config{
APIKey: cfg.Extension.APIKey,
APISecret: cfg.Extension.APISecret,
BaseURL: cfg.Extension.BaseURL,
Cluster: cfg.Cluster,
AccessMode: cfg.Extension.AccessMode,
Storage: store,
Callbacks: fdkext.Callbacks{
Auth: authCallback(cfg),
Uninstall: uninstallCallback(cfg),
},
WebhookConfig: &fdkext.WebhookConfig{
APIPath: WebhookPath,
NotificationEmail: "ops@example.com",
EventMap: map[string]fdkext.EventConfig{
"company/product/create": {Version: "2", Handler: onProductCreate},
},
},
})
Every event in EventMap is checked against the platform at startup, so a wrong name or version fails immediately rather than silently never firing. Note the Version — this event exists at v2, not v1.
Swap multilevel for redisstore.New(conns.Redis, cfg.Extension.Slug) if you do not want the Mongo tier.
Callbacks
The auth callback returns where the merchant lands after installing, and handles both launch contexts. For a sales-channel launch it also attaches the storefront proxy path — logging a failure rather than returning it, because that must not fail the install.
func authCallback(cfg *config.Config) func(*gin.Context) (string, error) {
return func(c *gin.Context) (string, error) {
companyID := c.Query("company_id")
applicationID := c.Query("application_id")
if applicationID != "" {
if err := AddProxyPath(c.Request.Context(), fdkext.Ext(c), cfg, companyID, applicationID); err != nil {
logger.Error("could not add proxy path", zap.Error(err))
}
return fmt.Sprintf("%s/company/%s/application/%s", cfg.Extension.BaseURL, companyID, applicationID), nil
}
return fmt.Sprintf("%s/company/%s", cfg.Extension.BaseURL, companyID), nil
}
}
fdkext.Ext(c) is the extension handling this request, put there by the session middleware, so the callback needs no reference of its own.
The uninstall callback is where you delete the data you hold for that company. If you called AddProxyPath, remove it here with DeleteProxyPath — the platform does not do it for you.
API Mounting
go-fit mounts one router type at the root, so both API groups are registered inside it.
routers := server.Routers{
Platform: func(rg *gin.RouterGroup) {
rg.Use(router.ErrorHandler())
api := rg.Group(cfg.APIPrefix())
router.Platform(ext)(api.Group("/platform/v1.0"))
router.Application(ext)(api.Group("/application/v1.0"))
},
}
// Init rather than Run: the engine is needed for the platform's callbacks and the frontend
engine := server.Init(ctx, routers)
// /fp/install, /fp/auth, /fp/auto_install, /fp/uninstall, /adm/install, /adm/auth
ext.RegisterRoutes(engine)
ext.RegisterRoutes is what adds the install and auth endpoints Fynd Commerce calls — the equivalent of mounting fdkHandler in the JavaScript templates.
API Routing
Routes registered through ext.PlatformAPIRoutes require a live session and get an authenticated platform client.
func registerPlatform(ext *fdkext.Extension, g *gin.RouterGroup) {
api := ext.PlatformAPIRoutes(g)
api.GET("/products", func(c *gin.Context) {
res, err := fdkext.PlatformClient(c).Catalog.GetProducts(c, catalog.GetProductsParams{})
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, res)
})
// Sales-channel products, for a launch scoped to one storefront
api.GET("/products/application/:application_id", func(c *gin.Context) {
client := fdkext.PlatformClient(c).Application(c.Param("application_id"))
res, err := client.Catalog.GetAppProducts(c, catalog.GetAppProductsApplicationParams{})
...
})
}
The frontend sends the company in an x-company-id header; requests without a valid session get a 401. Passing errors to c.Error(err) routes them to router.ErrorHandler().
With the prefix and group above, the products route is served at /api/platform/v1.0/products in local development. The bundled frontend's service calls must use the same paths — if a request 404s, compare the path in frontend/pages/Home.jsx with the groups registered in main.go.
Webhook Handler
The webhook route is mounted directly on the engine, because its path is fixed by the config the library registered with the platform.
engine.POST(extension.WebhookPath, func(c *gin.Context) {
// ProcessWebhook verifies the hmac signature before dispatching to the handler
if err := ext.Webhooks().ProcessWebhook(c); err != nil {
logger.Error("webhook processing failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
})
The handler receives the raw, signature-verified payload. Unmarshal only the fields you need — event payloads carry a lot and grow over time.
func onProductCreate(_ context.Context, event fdkext.WebhookEvent) error {
var payload struct {
Product struct {
UID int `json:"uid"`
Name string `json:"name"`
} `json:"product"`
}
if err := json.Unmarshal(event.Payload, &payload); err != nil {
return fmt.Errorf("decoding %s: %w", event.Name, err)
}
...
}
An error from the handler makes ProcessWebhook fail, which tells the platform to redeliver. Swallow what should not be retried, or a permanent failure is redelivered indefinitely.
Frontend routes
The frontend is a single-page app, so a hard refresh on /company/1 has to return index.html rather than a 404. Reserved prefixes are excluded so a genuine mistake still shows up as a 404 instead of being hidden behind the app's HTML.
engine.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
for _, reserved := range []string{"/api/", "/fp/", "/adm/", "/_healthz", "/_readyz"} {
if len(path) >= len(reserved) && path[:len(reserved)] == reserved {
c.JSON(http.StatusNotFound, gin.H{"message": "not found"})
return
}
}
c.File(indexPath)
})
Frontend
The frontend is the shared example-extension-react submodule — the same React app the Node and Java templates use, so the examples differ only in the backend. See the Frontend section of the Node + React page for the component walkthrough.
Clone with the submodule, or initialise it afterwards:
git submodule update --init --recursive
cd frontend && npm install
In development Vite serves the app and proxies API calls to the Go process; in production the built assets in frontend/public/dist are served by the Go server itself.