router

Websites Puter Apps Node.js Workers

Puter workers use a router-based system to handle HTTP requests. The router object is automatically available in your worker code and provides methods to define API endpoints.

Syntax

router.post("/my-endpoint", async ({ request, user, params }) => {
  return { message: "Hello, World!" };
});

Router Basics

The router object supports standard HTTP methods and provides a clean way to organize your API endpoints.

HTTP Methods

  • router.get(path, handler) - Handle GET requests
  • router.post(path, handler) - Handle POST requests
  • router.put(path, handler) - Handle PUT requests
  • router.delete(path, handler) - Handle DELETE requests
  • router.options(path, handler) - Handle OPTIONS requests

Handler Parameters

Route handlers receive a single object as their parameter, which can be destructured into the following properties:

  • request - The incoming HTTP request.
  • user - An object representing the user who made the request to this worker. It has a puter property (user.puter) that gives you access to that user's own Puter resources — KV, FS, AI, etc. Only available when the worker is called via puter.workers.exec().
  • params - Route parameters captured from the path (see Route Parameters)

Global Objects

When writing worker code, you have access to these global objects:

  • router - The router object for defining API endpoints
  • me - An object representing you, the worker's owner. It has a puter property (me.puter) that gives you access to your own Puter resources — KV, FS, AI, etc.

Integration with Puter.js

Just like in apps or websites, you can use Puter.js in workers to access AI, cloud storage, key-value stores, and databases.

The difference is whose resources you use. A worker gives you two .puter objects to work with, and operations are billed to whichever one you call:

  • me.puter is the worker context — your own resources, as the owner. Use this for shared application data, server-side logic, and centralized resources you control. Operations run against your account and are billed to you.
  • user.puter is the user context — the resources of the user who called the worker (available when it's executed via puter.workers.exec(), which runs it with their token). This keeps the default User-Pays model: each user's data stays in their own storage, billed to them, while your logic still runs server-side.

So you can mix and match within the same codebase — some endpoints reading and writing your own data (me.puter), others acting on the calling user's data (user.puter).

Route Parameters

Sometimes part of a path isn't fixed — like a post ID or a username. You can capture these segments by prefixing them with a colon (:) in the route path. Each captured segment becomes a property on the params object, keyed by the name you gave it.

router.get("/api/posts/:category/:id", async ({ params }) => {
  const { category, id } = params;
  return { category, id };
});

A request to /api/posts/tech/42 matches this route and gives you:

  • params.category"tech"
  • params.id"42"

You can use as many route parameters as you need. Captured values are always strings, so convert them yourself if you expect a number.

Wildcard Routes

While a route parameter (:name) matches a single segment, a wildcard (*name) matches the rest of the path — any number of segments. Like a route parameter, the matched value is available on params, keyed by the name after the *.

router.get("/files/*path", async ({ params }) => {
  // A request to /files/images/avatars/me.png gives:
  // params.path === "images/avatars/me.png"
  return { path: params.path };
});

A common use is a catch-all route for unmatched paths — define it last so it only runs when nothing else matched (see the 404 Handler example below).

CORS

Every response from your worker automatically includes Access-Control-Allow-Origin: *, so simple cross-origin requests work out of the box — a basic GET or POST from another origin just works, no extra code.

Some requests need a CORS preflight first: the browser sends an OPTIONS request and waits for the allowed methods and headers before sending the real one. This happens when the request uses a method like PUT or DELETE, or carries custom headers (e.g. Authorization).

To handle this, you can add an OPTIONS handler that returns the methods and headers you want to allow:

router.options("/*path", async () => {
  return new Response(null, {
    status: 204,
    headers: {
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
      "Access-Control-Allow-Headers": "Content-Type, Authorization, puter-auth",
    },
  });
});

This answers the preflight for any path with the CORS headers the browser expects, so your other routes work cross-origin.

The puter-auth header is important: when you call your worker with puter.workers.exec(), it attaches the user's Puter token in a puter-auth header so the worker can act on the calling user's behalf (this is what populates user.puter). Because that's a custom header, the browser runs a preflight first — so puter-auth must be listed in Access-Control-Allow-Headers, otherwise the preflight fails and the request never reaches your worker.

If you need different CORS rules per endpoint — for example, restricting the allowed methods or headers on a specific route — define an OPTIONS handler on that individual path instead of using the wildcard.

Examples

Basic Router Structure

The example above is a simple GET endpoint that returns a JSON object with a message.

router.get("/api/hello", async ({ request }) => {
  // Simple GET endpoint
  return { message: "Hello, World!" };
});

Accessing Request JSON Body

router.post("/api/user", async ({ request }) => {
  // Get JSON body
  const body = await request.json();
  return { processed: true };
});

Accessing Request Form Data

router.post("/api/user", async ({ request }) => {
  // Get form data
  const formData = await request.formData();
  return { processed: true };
});

Query Parameters

router.get("/api/search", async ({ request }) => {
  // Read query string parameters from the URL
  const url = new URL(request.url);
  const query = url.searchParams.get("q");
  return { query };
});

Accessing Request Headers

router.post("/api/user", async ({ request }) => {
  // Get headers
  const contentType = request.headers.get("content-type");
  return { processed: true };
});

Route Parameters

Use :name in your route path to capture route parameters:

router.get("/api/posts/:category/:id", async ({ request, params }) => {
  const { category, id } = params;
  return { category, id };
});

JSON Response

router.get("/api/simple", async ({ request }) => {
  return { status: "ok" }; // Automatically converted to JSON
});

Plain Text Response

router.get("/api/text", async ({ request }) => {
  return "Hello World"; // Returns plain text
});

Blob Response

router.get("/api/blob", async ({ request }) => {
  return new Blob(["Hello World"], { type: "text/plain" });
});

Uint8Array Response

router.get("/api/uint8array", async ({ request }) => {
  return new Uint8Array([72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100]);
});

Binary Stream Response

router.get("/api/binary-stream", async ({ request }) => {
  return new ReadableStream({
    start(controller) {
      controller.enqueue(
        new Uint8Array([72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100])
      );
      controller.close();
    },
  });
});

Custom Response Objects

router.get("/api/custom", async ({ request }) => {
  return new Response(JSON.stringify({ data: "custom" }), {
    status: 200,
    headers: {
      "Content-Type": "application/json",
      "Custom-Header": "value",
    },
  });
});

Returning Custom Error Responses

You can also return custom error responses. To do so, you can use the Response object and set the status code and headers.

router.post("/api/risky-operation", async ({ request }) => {
  try {
    const body = await request.json();
    const result = await someRiskyOperation(body);
    return { success: true, result };
  } catch (error) {
    return new Response(
      JSON.stringify({
        error: "Operation failed",
        message: error.message,
      }),
      {
        status: 500,
        headers: { "Content-Type": "application/json" },
      }
    );
  }
});

Worker Context vs User Context

The same operation can run against either Puter account. Here, one endpoint reads from the calling user's KV store (user.puter), the other from your own (me.puter).

// Read from the calling user's KV store (user context)
router.get("/api/kv/user/get", async ({ request, user }) => {
  const url = new URL(request.url);
  const key = url.searchParams.get("key");
  const value = await user.puter.kv.get(key);
  return { value };
});

// Read from the worker owner's KV store (worker context)
router.get("/api/kv/worker/get", async ({ request }) => {
  const url = new URL(request.url);
  const key = url.searchParams.get("key");
  const value = await me.puter.kv.get(key);
  return { value };
});

File System Integration

router.post("/api/upload", async ({ request }) => {
  const formData = await request.formData();
  const file = formData.get("file");

  if (!file) {
    return new Response(JSON.stringify({ error: "No file provided" }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    });
  }

  const fileName = `upload-${Date.now()}-${file.name}`;
  await me.puter.fs.write(fileName, file);

  return {
    uploaded: true,
    fileName,
    originalName: file.name,
    size: file.size,
  };
});

Key-Value Store (NoSQL Database) Integration

router.post("/api/kv/set", async ({ request }) => {
  const { key, value } = await request.json();

  if (!key || value === undefined) {
    return new Response(JSON.stringify({ error: "Key and value required" }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    });
  }

  await me.puter.kv.set("myscope_" + key, value); // add a mandatory prefix so this wont blindly read the KV of the user's other data
  return { saved: true, key };
});

router.get("/api/kv/get/:key", async ({ request, params }) => {
  const key = params.key;
  const value = await me.puter.kv.get("myscope_" + key); // use the same prefix

  if (!value) {
    return new Response(JSON.stringify({ error: "Key not found" }), {
      status: 404,
      headers: { "Content-Type": "application/json" },
    });
  }

  return { key, value: value };
});

AI Integration

router.post("/api/chat", async ({ request, user }) => {
  const { message } = await request.json();

  if (!message) {
    return new Response(JSON.stringify({ error: "Message required" }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    });
  }

  // Require user authentication to prevent abuse
  if (!user || !user.puter) {
    return new Response(
      JSON.stringify({
        error: "Authentication required",
        message:
          "This endpoint requires user authentication. Call this worker via puter.workers.exec() with your user token to use your own AI resources.",
      }),
      {
        status: 401,
        headers: { "Content-Type": "application/json" },
      }
    );
  }

  try {
    // Use user's AI resources
    const aiResponse = await user.puter.ai.chat(message);

    // Store chat history in developer's KV for analytics
    const chatHistory = {
      userId: user.id || "unknown",
      message,
      response: aiResponse,
      timestamp: new Date().toISOString(),
      usedUserAI: true,
    };
    await me.puter.kv.set(`chat_${Date.now()}`, chatHistory);

    return {
      originalMessage: message,
      aiResponse,
      usedUserAI: true,
    };
  } catch (error) {
    return new Response(
      JSON.stringify({
        error: "AI service error",
        message: error.message,
      }),
      {
        status: 500,
        headers: { "Content-Type": "application/json" },
      }
    );
  }
});

404 Handler

Always include a catch-all route for unmatched paths:

router.get("/*page", async ({ request, params }) => {
  const requestedPath = params.page;

  return new Response(
    JSON.stringify({
      error: "Not found",
      path: requestedPath,
      message: "The requested endpoint does not exist",
      availableEndpoints: ["/api/hello", "/api/data", "/api/upload"],
    }),
    {
      status: 404,
      headers: { "Content-Type": "application/json" },
    }
  );
});

Complete Example

Here's a complete worker with multiple endpoints demonstrating various router patterns:

// Health check
router.get("/health", async () => {
  return {
    status: "ok",
    timestamp: new Date().toISOString(),
  };
});

// User management API
router.post("/api/users", async ({ request, user }) => {
  const userInfo = await user.puter.getUser();

  // Store user data
  const userId = `user_${Date.now()}`;
  await me.puter.kv.set(userId, {
    email: userInfo.email,
    name: userInfo.username,
  });

  return {
    userId,
    user: {
      email: userInfo.email,
      username: userInfo.username,
      uuid: userInfo.uuid,
    },
  };
});

router.get("/api/users/:id", async ({ params }) => {
  const userId = params.id;
  if (!userId.startsWith("user_"))
    // security check
    return new Response("Invalid userID!");
  const userData = await me.puter.kv.get(userId);

  if (!userData) {
    return new Response(
      JSON.stringify({
        error: "User not found",
      }),
      {
        status: 404,
        headers: { "Content-Type": "application/json" },
      }
    );
  }

  return { userId, user: userData };
});

// File operations
router.post("/api/files/upload", async ({ request }) => {
  const formData = await request.formData();
  const file = formData.get("file");

  if (!file) {
    return new Response(
      JSON.stringify({
        error: "No file provided",
      }),
      {
        status: 400,
        headers: { "Content-Type": "application/json" },
      }
    );
  }

  const fileName = `upload-${Date.now()}-${file.name}`;
  await me.puter.fs.write(fileName, file);

  return {
    uploaded: true,
    fileName,
    originalName: file.name,
    size: file.size,
  };
});

// 404 handler
router.get("/*tag", async ({ params }) => {
  return new Response(
    JSON.stringify({
      error: "Not found",
      path: params.tag,
      availableEndpoints: ["/health", "/api/users", "/api/files/upload"],
    }),
    {
      status: 404,
      headers: { "Content-Type": "application/json" },
    }
  );
});

Testing Your Router

After deploying your worker, test your endpoints:

// Test your worker endpoints
const workerUrl = "https://your-worker.puter.work";

// Test GET endpoint
const response = await puter.workers.exec(`${workerUrl}/api/hello`);
const data = await response.json();
console.log(data);

// Test POST endpoint
const postResponse = await puter.workers.exec(`${workerUrl}/api/data`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ key: "test", value: "hello" }),
});
const postData = await postResponse.json();
console.log(postData);