HTTP Routes 

Gadget allows you to run code whenever someone accesses a specific URL on your domain. The combination of a URL pattern and handler is known as a route. Gadget's routes are built on Fastify routes, so your handlers can use all of Fastify's features like body parsing, validation, and middleware.

Anatomy of a route 

HTTP routes in Gadget are defined as files in the routes folder which export a single function accepting a request and a reply object. The route is served at a given URL path defined by the route's filename, similar to Next.js routes. Gadget runs the function when a request is made to the route's URL, and the function can do whatever it wants with the request and reply object, like rendering an HTML view, sending a JSON response, or redirecting the user based on some cookies.

Here's an example HTTP route which renders the string "world" when a request is made to /hello:

routes/GET-hello.js
module.exports = async (request, reply) => {
reply.send("world");
};

URL patterns 

The URL pattern for a route in Gadget is defined by the path to the handler's source, relative to a routes/ directory. The name of the file is also prefixed with the HTTP verb it should handle. For example, if your gadget app is my-app.gadget.app and you have a source file at routes/test/GET-data.js, someone accessing https://my-app.gadget.app/test/data in the browser would run the handler in that source file.

Dynamic URLs 

If you have a dynamic segment in your URL, such as an id to some Gadget resource, you can capture that piece of information by prefixing with a colon (:). For example, routes/blog/post/GET-:id would capture id=5 when accessing /blog/post/5. You can then access these captured segments in request.params when writing a route handler.

You can also capture part of a path segment, as long as it's separated by a dash. For example, routes/blog/:id/GET-post-:postId would capture id=3, postId=10 when accessing /blog/3/post-10. If you need a colon as part of a path segment, use two colons. For example, routes/GET-blog::post.js would match /blog:post.

If you need more flexibility in how parts of the path are captured, you can manually register a route through a route plugin.

All JS/TS files in the routes/ directory must be prefixed with an HTTP verb for route handlers or a + for route plugin. Any other filename under the routes/ directory will break your app.

Root URLs 

If you want to register a route for the root of your application, or a route at the root of a folder, name the file after just the HTTP verb. For example, routes/GET.js will match requests to /, or routes/foo/bar/POST.js will match routes to /foo/bar.

Available HTTP methods 

Route handler functions can listen to any of the standard HTTP methods (also known as verbs) by using that HTTP method as the first part of the file name:

  • GET, like GET-list.js
  • POST, like POST-create.js
  • PUT, like PUT-update.js
  • PATCH, like PUT-update.js
  • DELETE
  • HEAD
  • OPTIONS

Route handlers can also be prefixed with ANY to respond to any HTTP method, like ANY-data.js. If you want to know which HTTP method was used to make a request, you can use the request.method property.

ANY-example.js
1module.exports = async (request, reply) => {
2 if (request.method == "GET") {
3 reply.send("hello world");
4 } else if (request.method == "POST") {
5 reply.send(`updated: ${request.body}`);
6 } else {
7 reply.code(404).send("not found");
8 }
9};

Route handlers 

Route handlers should export a single function that takes a request and a reply parameter.

Plain text response
module.exports = async (request, reply) => {
reply.send("hello world");
};
JSON response
module.exports = async (request, reply) => {
reply.send({ message: "hello world" });
};
Plain text response with custom status code
1module.exports = async (request, reply) => {
2 reply
3 .code(418)
4 .type("text/plain; charset=utf-8")
5 .send("I can't brew you any coffee because I'm a teapot!");
6};
Accessing a captured id argument
1// /routes/quotes/GET-:id.js
2module.exports = async (request, reply) => {
3 if (request.params.id == "1") {
4 reply.send("To be or not to be");
5 } else {
6 reply.code(404).send(`Unknown quote id: ${request.params.id}`);
7 }
8};

Route options 

Fastify, the node framework powering Gadget's routes, accepts a wide variety of options to change route behaviour. Route options can be passed to fastify by setting the options property on the route handler function that you export.

Setting route options to validate request
1module.exports = async (request, reply) => {
2 reply.send(`hello ${request.query.name}`);
3};
4module.exports.options = {
5 // add route options like `schema`, `errorHandler`, etc here. for example, we validate the query string has a name key
6 schema: {
7 querystring: {
8 type: "object",
9 properties: {
10 name: { type: "string" },
11 },
12 required: ["name"],
13 },
14 },
15};

Supported Fastify route options:

  • schema: an object containing the schemas for the request and response. They need to be in JSON Schema format, check fastify's docs for more info.

    • body: validates the body of the request if it is a POST, PUT, or PATCH method.
    • querystring or query: validates the querystring. This can be a complete JSON Schema object, with the property type of object and properties object of parameters, or simply the values of what would be contained in the properties object as shown below.
    • params: validates the params.
    • response: filter and generate a schema for the response, setting a schema allows us to have 10-20% more throughput.
  • exposeHeadRoute: creates a sibling HEAD route for any GET routes. Defaults to the value of exposeHeadRoutes instance option. If you want a custom HEAD handler without disabling this option, make sure to define it before the GET route.

  • attachValidation: attach validationError to request, if there is a schema validation error, instead of sending the error to the error handler. The default error format is the Ajv one.

  • onRequest(request, reply, done): a function as soon that a request is received, it could also be an array of functions.

  • preParsing(request, reply, done): a function called before parsing the request, it could also be an array of functions.

  • preValidation(request, reply, done): a function called after the shared preValidation hooks, useful if you need to perform authentication at route level for example, it could also be an array of functions.

  • preHandler(request, reply, done): a function called just before the request handler, it could also be an array of functions.

  • preSerialization(request, reply, payload, done): a function called just before the serialization, it could also be an array of functions.

  • onSend(request, reply, payload, done): a function called right before a response is sent, it could also be an array of functions.

  • onResponse(request, reply, done): a function called when a response has been sent, so you will not be able to send more data to the client. It could also be an array of functions.

  • onTimeout(request, reply, done): a function called when a request is timed out and the HTTP socket has been hanged up.

  • onError(request, reply, error, done): a function called when an Error is thrown or send to the client by the route handler.

  • handler(request, reply): the function that will handle this request. The Fastify server will be bound to this when the handler is called. Note: using an arrow function will break the binding of this.

  • errorHandler(error, request, reply): a custom error handler for the scope of the request. Overrides the default error global handler, and anything set by setErrorHandler in plugins, for requests to the route. To access the default handler, you can access instance.errorHandler. Note that this will point to fastify's default errorHandler only if a plugin hasn't overridden it already.

  • validatorCompiler({ schema, method, url, httpPart }): function that builds schemas for request validations. See the Validation and Serialization documentation.

  • serializerCompiler({ { schema, method, url, httpStatus } }): function that builds schemas for response serialization. See the Validation and Serialization documentation.

  • schemaErrorFormatter(errors, dataVar): function that formats the errors from the validation compiler. See the Validation and Serialization documentation. Overrides the global schema error formatter handler, and anything set by setSchemaErrorFormatter, for requests to the route.

  • bodyLimit: prevents the default JSON body parser from parsing request bodies larger than this number of bytes. Must be an integer. You may also set this option globally when first creating the Fastify instance with fastify(options). Defaults to 1048576 (1 MiB).

  • logLevel: set log level for this route.

  • logSerializers: set serializers to log for this route.

  • config: object used to store custom configuration.

  • prefixTrailingSlash: string used to determine how to handle passing / as a route with a prefix.

    • both (default): Will register both /prefix and /prefix/.
    • slash: Will register only /prefix/.
    • no-slash: Will register only /prefix.

See Fastify's docs for exhaustive documentation on these route options.

Route context 

The request parameter provides many of the same context properties that are available in code effects. In addition to all the features available in Fastify, the request parameter will also have the following keys:

  • api: A connected, authorized instance of the generated API client for the current Gadget application. See the API Reference for more details on this object's interface.
  • applicationSession: A record representing the current user's session, if there is one.
  • applicationSessionID: The ID of the record representing the current user's session, if there is one.
  • connections: An object containing client objects for all connections. Read the connections guide to see what each connection provides.
  • logger: A logger object suitable for emitting log entries viewable in Gadget's Log Viewer.
  • config: An object of all the environment variables created in Gadget's Environment Variables editor.
  • currentAppUrl: The current url for the environment. e.g. https://my-app.gadget.app

For example, we can use the request.api object to make API calls to your application's API, and return them as JSON:

routes/GET-example.js
JavaScript
module.exports = async (request, reply) => {
const records = await request.api.someModel.findMany();
await reply.code(200).send({ result: records });
};

We can use the request.logger object to emit log statements about our request:

routes/GET-protected.js
JavaScript
1module.exports = async (request, reply) => {
2 if (request.headers["authorization"] == "Bearer secret-token") {
3 request.logger.info({ ip: request.ip }, "access granted");
4 await reply.code(200).send({ result: "the protected stuff" });
5 } else {
6 request.logger.info({ ip: request.ip }, "access denied");
7 await reply.code(403).send({ error: "access denied" });
8 }
9};

The request.connections object passed to each route handler contains a client object for each connection. For example, we can make an API call for a particular shopify shop:

routes/GET-protected.js
JavaScript
module.exports = async (request, reply) => {
// relies on this request being made from an embedded app to know what `shopify.current` is
const result = await request.connections.shopify.current.shop.get();
await reply.code(200).send({ result });
};

Here's a full example demonstrating use of many of the elements of the request context:

routes/GET-my-route.ts
JavaScript
1module.exports = async (request, reply) => {
2 const { api, applicationSession, connections, logger, config } = request;
3
4 // use "applicationSession" to the get current shopId
5 const shopId = applicationSession?.get("shop");
6
7 // if there's a current in context shopify client let's continue
8 if (connections.shopify.current) {
9 const product = await api.shopifyProduct.findById(request.body.productId);
10
11 // if the product belongs to the shop then update product with description from body
12 if (product.get("shop") == shopId) {
13 await connections.shopify.current.product.update(request.body.productId, {
14 body: request.body.productDescription,
15 });
16
17 // update count of updated products
18 const updatedProductRecord = await api.updatedProduct.findFirst({
19 filter: {
20 shop: {
21 equals: shopId,
22 },
23 },
24 });
25 // atomically increment the number of products we've updated in our updatedProduct model
26 await api.internal.updatedProduct.update(updatedProductRecord.id, {
27 updatedProduct: {
28 _atomics: {
29 count: {
30 increment: 1,
31 },
32 },
33 },
34 });
35
36 // notify me via sms
37 const twilio = require("twilio");
38 // use "config" to pass along environment variables required by Twilio
39 const client = new twilio(config.accountSid, config.authToken);
40
41 await client.messages.create({
42 body: "Product has been updated!",
43 to: "+12345678901",
44 from: "+12345678901",
45 });
46
47 logger.info(
48 { productId: request.body.productId, shopId },
49 "a product has been updated"
50 );
51
52 await reply.send();
53 } else {
54 // couldn't find the product!
55 await reply.status(404).send();
56 }
57 } else {
58 // oops not authorized
59 await reply.status(401).send();
60 }
61};

Route plugins 

Route plugins are files that customize Fastify for the surrounding routes. You add a route plugin by creating a file starting with a + sign in a particular folder of routes, and that plugin then modifies the Fastify server for all the routes in that folder (or subfolders of it), and doesn't modify any other routes.

Route plugins can do many different things:

  • Adding other Fastify plugins like fastify-cors, fastify-view, ...
  • Decorating requests with a logged-in user for authentication
  • Redirecting anonymous users to a login page
  • Checking user credentials for permissions to access a set of routes
  • Conditionally registering routes in loops or if statements with server.get, ...

Route plugins are a function that looks like a regular Fastify plugin. Plugin files should export a single function that takes a Fastify server instance.

Register point-of-view for HTML rendering
1// in routes/+views.js
2const FastifyView = require("point-of-view");
3module.exports = async (server) => {
4 await server.register(FastifyView, {
5 engine: {
6 eta: require("eta"), // an example view engine, see https://eta.js.org/
7 },
8 });
9};
Redirect when missing a header
1// in routes/+redirect.js
2module.exports = async (server) => {
3 server.addHook("preHandler", async (request, reply) => {
4 if (request.headers["secret-value"] != "12345") {
5 reply.redirect("/index");
6 }
7 });
8};
Register a route with a regex path
1// in routes/+custom-routes.js
2module.exports = async (server) => {
3 server.get("/example/:file(^d+).png", (request, reply) => {
4 reply.send({ filenameWithoutExtension: request.params.file });
5 });
6};

What routes do route plugins affect 

Route plugins are applied to all routes in the same folder as the plugin, and all subfolders of that folder. For example, if you have a plugin in routes/admin/+auth.js, it will affect all routes in routes/admin and all subfolders of routes/admin, such as routes/admin/users and routes/admin/posts. It will not affect routes in other folders, such as routes/public or routes/blog.

1routes/
2 +plugins.js // will affect all routes in any folder
3 admin/
4 +auth.js // will only affect routes in the admin folder
5 GET.js
6 GET-posts.js
7 users/
8 +customizations.js // will only affect the one route below, and nothing in the routes/admin folder
9 GET.js
10 posts/
11 +images.js // will only affect these posts routes in routes/posts
12 GET.js
13 GET-:id.js

Boot plugins 

Boot plugins are Fastify server customizations that affect the whole server instead of just a part. They are registered before any other route plugins or the routes themselves. Boot plugins live in the root boot/ folder. The primary use case is to register some global state, such as creating an API client fo an external service. It isn't necessary to export anything from a boot plugin, but the only export supported is a function that will take a server instance.

Handle errors and missing routes
1// in boot/errors.js
2module.exports = async (server) => {
3 server.setNotFoundHandler(async (request, reply) => {
4 reply.send(`couldn't find ${request.path}`);
5 });
6 server.setErrorHandler(async (error, request, reply) => {
7 reply.send(`something failed: ${error.message}`);
8 });
9};

CORS configuration 

Gadget applications often serve requests made from domains other than your-app.gadget.app which makes them cross-domain and governed by browser Cross Origin Resource Sharing (CORS) rules. If you're making requests to Gadget from a Next.js app hosted on another domain, a Shopify storefront, or anywhere other than your-app.gadget.app, you need to make sure you have configured your app to respond to CORS requests correctly.

To set up CORS headers for your app, we suggest using fastify-cors. fastify-cors is a high-quality plugin for handling CORS requests from the Fastify ecosystem available on npm. To start, add it to your package.json, and Run yarn to install it:

package.json
json
1{
2 "dependencies": {
3 "fastify-cors": "6.0.3"
4 // ...
5 }
6}

After, you can mount fastify-cors in your application with a Route plugin file at routes/+scope.js:

routes/+scope.js route plugin file
JavaScript
1const FastifyCors = require("fastify-cors");
2module.exports = async (server) => {
3 await server.register(FastifyCors, {
4 // allow CORS requests from my-cool-frontend.com
5 origin: ["https://my-cool-frontend.com"],
6 // allow GET, POST, and PUT requests
7 methods: ["GET", "POST", "PUT"],
8 // other options, see here: https://www.npmjs.com/package/fastify-cors/v/6.0.3
9 });
10};

When your application boots, this route plugin will be required, and configure fastify-cors to send the right Access-Control-Allow-Origin header to browsers.

Just like all route plugins, +scope.js files that mount fastify-cors will only affect requests to route contained in the same folder/subfolder as the +scope.js file. Registering fastify-cores directly in routes/+scope.js will affect all the routes of your app, or registering it in routes/api/+scope.js will only affect requests to any route in that routes/api folder. This allows different CORS configurations for different parts of your application.

Allowing requests from any domain 

If your app is intended for any domain, you can configure fastify-cors to allow any origin to make requests to your app with origin: true.

routes/+scope.js route plugin file
JavaScript
1const FastifyCors = require("fastify-cors");
2module.exports = async (server) => {
3 await server.register(FastifyCors, {
4 origin: true, // allow requests from any domain
5 });
6};

Fine-grained CORS configuration 

If you want to avoid setting CORS headers for your entire application, you can also manually manage CORS headers using reply.header in a route file, or add OPTIONS-*.js routes for handling CORS preflight requests yourself.

For example, we can reply with a base set of CORS headers right in a route file:

routes/POST-foo.js route file
JavaScript
1module.exports = async (request, reply) => {
2 // set reply headers for CORS explicitly
3 await reply.headers({
4 "access-control-allow-origin": "*",
5 "access-control-allow-methods": "POST, GET, OPTIONS",
6 "access-control-allow-headers": "Content-Type",
7 });
8 // send the response
9 reply.send("foo");
10};

Or for cross-origin requests which require preflight requests, we can handle these requests explicitly by adding an OPTIONS-foo.js route handler:

routes/OPTIONS-foo.js route file
JavaScript
1// OPTIONS route handler for an adjacent routes/PUT-foo.js route file
2module.exports = async (request, reply) => {
3 // set reply headers for CORS preflight request
4 await reply.headers({
5 "access-control-allow-origin": "*",
6 "access-control-allow-methods": "PUT, GET, OPTIONS",
7 "access-control-allow-headers": "Content-Type",
8 });
9 // send no response to meet the preflight request spec
10 reply.send("").code(200);
11};

Note that if using fastify-cors, you don't need to add manual CORS header setting or explicit OPTIONS-*.js routes as fastify-cors handles this for you.

Multipart requests 

Gadget applications don't support requests that use Content-Type: multipart/form-data (multipary requests) by default, but you can add multipart support using the fastify-multipart package from npm.

If you're processing file uploads, you can use Gadget's built in support for file storage and uploading using the file field type. See the Storing Files guide for more information.

First, you need to install fastify-multipart from npm. You can add it to your app by adding this to your package.json:

package.json
json
{
"dependencies": {
"fastify-multipart": "5.3.1"
}
}

Next, you need to mount fastify-multipart into your server with a boot plugin or route plugin. A boot plugin will register it globally, and a route plugin will register it for only some routes. Let's use a boot plugin to keep things simple:

boot/multipart.js
JavaScript
const FastifyMultipart = require("fastify-multipart");
module.exports = async (server) => {
await server.register(FastifyMultipart);
};

With the plugin registered, you can access POSTed file data using request.file or request.files in your routes:

routes/POST-upload.js
JavaScript
1module.exports = async (request, reply) => {
2 // process a single file
3 const data = await req.file();
4
5 data.file; // the stream of file data, is a node ReadableStream object
6 data.fields; // other parsed parts of the multipary request
7 data.fieldname; // the name of the key the file is stored under in the incoming data
8 data.filename; // the name of the file if provided
9 data.encoding; // the file encoding
10 data.mimetype; // the mimetype inferred from the file using the extension
11
12 // do something with the file
13
14 reply.send({ status: "ok" });
15};

For more information on working with fastify-multipart, see the fastify-multipart docs.