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
const handler = async (request, reply) => {
reply.send("world");
};
export default handler;

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
1const handler = 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};
10
11export default handler;

Route handlers

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

Plain text response
const handler = async (request, reply) => {
reply.send("hello world");
};
export default handler;
JSON response
const handler = async (request, reply) => {
reply.send({ message: "hello world" });
};
export default handler;
Plain text response with custom status code
1const handler = 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};
7export default handler;
Accessing a captured id argument
1// /routes/quotes/GET-:id.js
2const handler = 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};
9export default handler;

Route options

Fastify, the node framework powering Gadget's routes, accepts a wide variety of optionsto 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 plugins

Route plugins allow you to customize all of the routes under the current path. This can be useful for many 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 also exist in the routes/ folder, and are prefixed with a plus +. Route plugins are just regular Fastify plugins scoped to the current path and all its subpaths. These plugins should export a single function that takes a Fastify server instance.

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

Boot plugins

Boot plugins are similar to route plugins, but they are registered before any routes or route plugins. The primary use case is to register some global state, such as creating an API client to 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
1const plugin = async (server) => {
2 server.setNotFoundHandler(async (request, reply) => {
3 reply.send(`couldn't find ${request.path}`);
4 });
5 server.setErrorHandler(async (error, request, reply) => {
6 reply.send(`something failed: ${error.message}`);
7 });
8};
9export default plugin;

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.