DEV Community

Yigit Konur
Yigit Konur

Posted on

Creating a Handwritten Deno SDK for RESTful APIs: The Complete Guide

This guide walks you through building a handwritten Deno SDK for a RESTful HTTP API, covering everything from project setup to publishing on Deno's JavaScript Registry (JSR). The focus is on making your SDK run smoothly in both standard Deno and Supabase Edge Functions (which run on Deno Deploy).

Why Handwrite Your SDK?

Writing the SDK by hand instead of generating it from an OpenAPI spec gives you complete control. You can make the library lightweight, idiomatic, and perfectly tuned for the Deno runtime. Handwritten code avoids the bloat and quirks that auto-generated clients often introduce. You get direct integration with Deno's APIs like fetch, better security practices, and code that's more ergonomic for developers and easier to maintain over time.

Let's dive in step by step.


1. Project Setup and Structure

Start with a clear, idiomatic structure. Deno modules typically use mod.ts at the root as the entry point (similar to Rust modules). This file re-exports the main functionality of your SDK. Avoid using index.ts—Deno doesn't treat it specially like Node does, which can cause confusion.

Directory Layout

Organize code into modules so it's easy to maintain and so consumers can import what they need without pulling in everything:

my-api-sdk/
├── mod.ts                # Entry point: exports main client and types
├── client.ts             # Core API client implementation
├── endpoints/
│   ├── users.ts          # Module for "users" API endpoints
│   └── projects.ts       # Module for "projects" API endpoints
├── types.ts              # Type definitions for request/response payloads
├── utils.ts              # Utility functions (e.g. for building queries)
├── tests/
│   └── client_test.ts    # Unit tests for the client
├── README.md             # Documentation and usage examples
├── deno.json             # Deno configuration (including JSR package info)
└── deno.lock             # Lock file for dependencies (auto-generated)
Enter fullscreen mode Exit fullscreen mode

In mod.ts, re-export the main components so consumers only need to import from the top-level module:

export { APIClient } from "./client.ts";
export type { User, Project } from "./types.ts";
Enter fullscreen mode Exit fullscreen mode

This lets users do import { APIClient } from "https://deno.land/x/my_api_sdk/mod.ts"; without digging into subfolders.

Why Modular Design Matters

Splitting your code into multiple files keeps things organized and prevents one giant file. It also helps with dependency management—you avoid pulling in heavy dependencies unless absolutely needed. This is crucial for Deno Deploy and Supabase Edge Functions because any imported code runs at deployment time. Unnecessary code slows down cold starts or causes compatibility issues.

For example, if most API calls are simple fetches but one endpoint requires a PDF generation library, keep that in a separate module. Don't export it from mod.ts by default. Let consumers import it separately only when needed. As one Deno developer notes, "it's unwise to export all functions in mod.ts... because that would mean users download [e.g. a Puppeteer] dependency every time, even if they don't need that functionality." The key is thinking critically about your dependency tree and keeping the entry point lean.

Deno Configuration

Create a deno.json (or deno.jsonc) file in your project root. This file configures TypeScript settings, import maps, linting rules, and JSR package fields:

{
  "compilerOptions": {
    "strict": true
  },
  "imports": {
    "@my-sdk/": "./"
  },
  "lint": {
    "rules": {
      "tags": ["recommended", "jsr"],
      "include": [
        "explicit-function-return-type",
        "explicit-module-boundary-types",
        "camelcase",
        "single-var-declarator",
        "no-console"
      ]
    }
  },
  "fmt": {
    "indentWidth": 2,
    "lineWidth": 100,
    "singleQuote": false
  },
  "package": {
    "name": "@yourname/my-api-sdk",
    "version": "0.1.0",
    "description": "SDK for My API",
    "license": "MIT"
  }
}
Enter fullscreen mode Exit fullscreen mode

If using JSR, you can also put the package info in a separate jsr.json (more on that later).

Make sure strict type-checking is enabled to catch errors early and produce a robust SDK. The lint configuration uses the recommended and jsr rule tags as a baseline, plus additional rules for explicit typing and consistent style—we'll cover these in detail in Section 9.

In summary, a well-structured project uses a clear entry point (mod.ts), logical module breakdown, and a config file for Deno. This makes your SDK easy to navigate and consume.


2. Secure and Ergonomic API Key Handling

Most REST APIs require an API key or token for authentication. Handling this securely and ergonomically is crucial. The SDK should make it easy for developers to supply the key without risking exposure.

Never hard-code keys or secrets. Design your API client to accept credentials at runtime:

// client.ts
export interface APIClientOptions {
  apiKey: string;
  baseUrl?: string;
}

export class APIClient {
  #apiKey: string;
  #baseUrl: string;

  constructor(options: APIClientOptions) {
    this.#apiKey = options.apiKey;
    this.#baseUrl = options.baseUrl ?? "https://api.example.com/v1";
  }

  // ... (methods will use this.#apiKey)
}
Enter fullscreen mode Exit fullscreen mode

This approach is both ergonomic and secure:

  • Users retrieve the API key from their environment (or Supabase secret store) and pass it in:
  const client = new APIClient({ apiKey: Deno.env.get("MYAPI_KEY")! });
Enter fullscreen mode Exit fullscreen mode
  • The key is stored in a private field (#apiKey), not exposed publicly
  • The SDK should never log this key or accidentally include it in error messages or exceptions

Using Environment Variables

It's common to use environment variables for secrets. In Supabase Edge Functions, you define secrets in the dashboard or CLI, and they become available via Deno.env.get inside your function. For local development, developers can use a .env file or export environment variables.

Your SDK can leverage this by convention. For instance, you could allow APIClient to default to an env var if no key is provided:

if (!options.apiKey) {
  const envKey = Deno.env.get("MYAPI_KEY");
  if (!envKey) {
    throw new Error("API key not provided and MYAPI_KEY env var is not set");
  }
  this.#apiKey = envKey;
} else {
  this.#apiKey = options.apiKey;
}
Enter fullscreen mode Exit fullscreen mode

This way, developers can simply ensure MYAPI_KEY is set and call new APIClient({}). This is similar to how some official SDKs work—for example, the OpenAI Deno SDK will automatically read an OPENAI_API_KEY from the environment if you don't explicitly pass one. Note that if your library accesses Deno.env, users will need to run their code with --allow-env permission in Deno (or supply the key manually to avoid that requirement).

Best Practices

  • Never expose the key: All HTTP requests should send the key securely (usually in an HTTP header, e.g., Authorization: Bearer <token> or a custom header)
  • Avoid query params: Keys in URLs can end up in logs
  • Redact in debug logs: If your SDK prints debug info, omit or redact the key
  • Use Supabase secrets: Store API keys as secrets in your Supabase project and access via environment variables—this keeps secrets out of your code and repository

In your Supabase Edge Function code using the SDK:

const client = new APIClient({ apiKey: Deno.env.get("MYAPI_KEY")! });
Enter fullscreen mode Exit fullscreen mode

The Edge Function can then securely use the SDK without hardcoding the secret.

Ergonomics Tip

You might allow configuring the client with additional auth options, such as passing a custom header name or using different auth schemes (some APIs use API key in query param, etc.). Design the APIClientOptions accordingly, but provide sane defaults for the common case. For example, if the API expects Authorization: Bearer, just require the token and do the rest internally.


3. Implementing the API Client (HTTP Requests & Error Handling)

With structure and auth in place, implement the functions that call the RESTful API using Deno's HTTP capabilities. Deno provides the standard fetch() Web API for HTTP requests, which works on both the Deno CLI and Deno Deploy. This means your SDK can use fetch directly to call the remote API.

Making HTTP Requests

Inside your client methods, construct the request to the API endpoint:

/**
 * Retrieves a user by their unique identifier.
 * @param userId - The unique identifier of the user to retrieve.
 * @returns A promise that resolves to the User object.
 * @throws {APIError} When the API returns a non-OK response.
 * @example
 * ```

ts
 * const user = await client.getUser("abc123");
 * console.log(user.email);
 *

Enter fullscreen mode Exit fullscreen mode

*/
async getUser(userId: string): Promise {
const url = ${this.#baseUrl}/users/${userId};
const res = await fetch(url, {
method: "GET",
headers: {
"Authorization": Bearer ${this.#apiKey},
"Accept": "application/json"
}
});

if (!res.ok) {
// Handle HTTP error
const errorText = await res.text();
throw new APIError(res.status, errorText || res.statusText);
}

const data: unknown = await res.json();
return data as User;
}




In this snippet, `APIError` is a custom error class you define to capture HTTP status and message:



```typescript
/**
 * Custom error class for API-related errors.
 * Captures HTTP status code and error message for easier debugging.
 */
export class APIError extends Error {
  status: number;

  constructor(status: number, message: string) {
    super(`API request failed with status ${status}: ${message}`);
    this.status = status;
    this.name = "APIError";
  }
}
Enter fullscreen mode Exit fullscreen mode

Using a custom error class makes it easier for users to distinguish API errors from other errors (they can catch APIError). Make error messages clear and concise—they might surface in logs. Deno's style guide suggests using sentence case, no trailing period, and including relevant info. For example, "Cannot fetch user: received 401 Unauthorized" is more helpful than a vague "Failed request".

Strong TypeScript Types

Define TypeScript interfaces or types for the API's responses and request payloads in types.ts. This provides strong typing for anyone using your SDK, enabling autocompletion and compile-time checks:

/** Represents a user in the system. */
export interface User {
  /** Unique identifier for the user. */
  id: string;
  /** Display name of the user. */
  name: string;
  /** Email address of the user. */
  email: string;
}

/** Input parameters for creating a new user. */
export interface CreateUserInput {
  /** Display name for the new user. */
  name: string;
  /** Email address for the new user. */
  email: string;
  // ...other fields
}
Enter fullscreen mode Exit fullscreen mode

Then use these types in your methods:

/**
 * Creates a new user in the system.
 * @param input - The user data for creation.
 * @returns A promise that resolves to the newly created User.
 * @throws {APIError} When the API returns a non-OK response.
 */
async createUser(input: CreateUserInput): Promise<User> {
  const res = await fetch(`${this.#baseUrl}/users`, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${this.#apiKey}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify(input)
  });

  if (!res.ok) {
    const err = await res.text();
    throw new APIError(res.status, err || "Unknown error");
  }

  const data: unknown = await res.json();
  return data as User;
}
Enter fullscreen mode Exit fullscreen mode

By casting or declaring the JSON result as User, developers get typed data back (assuming the API returns JSON matching that interface). Ensure your types stay updated if the API changes. It's good to document in your README which version of the REST API your SDK is targeting.

Idiomatic TypeScript Usage

Embrace TypeScript features to make your SDK easy to use and maintain:

  • Avoid any entirely: Use unknown for truly dynamic values and narrow the type. The lint rule no-explicit-any will enforce this—using any undermines your SDK's type safety.
  • Use explicit return types: Every exported function should have an explicit return type annotation. This is enforced by explicit-function-return-type and explicit-module-boundary-types lint rules, and it's required for JSR's "no slow types" policy.
  • Narrow types where possible: If an endpoint returns one of several shapes, consider using a union type or discriminated union for the response.
  • Optional vs required options: Use optional properties in interfaces or function parameters for truly optional settings, and provide default behavior when not given (as we did with baseUrl defaulting to the API's base).
  • Follow Deno conventions: Exported functions should take at most 2 positional arguments and otherwise use an options object. If you have many config options for a function, group them in an object rather than having a long parameter list.
  • Use type-only imports: When importing something used only as a type, use import type { ... } to separate types from values. This is enforced by the verbatim-module-syntax lint rule and ensures your code works correctly across module systems.

Dependency Management

One advantage of Deno is that many things are built-in (fetch, URL, crypto APIs, etc.), so you might not need any third-party HTTP library or dotenv library as you would in Node. Keep external dependencies minimal. If you do need a dependency (say, a specific date library or a base64 encoder from Deno std), import it by URL and always pin the version:

import { encodeBase64 } from "https://deno.land/std@0.200.0/encoding/base64.ts";
Enter fullscreen mode Exit fullscreen mode

Pinning to a specific version ensures your SDK doesn't break unexpectedly if the dependency updates. The no-unversioned-import lint rule enforces this—unversioned imports can cause reproducibility issues and are flagged by JSR.

Also avoid Node-specific libraries; instead use Deno's equivalents or polyfills. This guarantees your SDK works on Supabase Edge (Deno Deploy), where Node built-ins might not be available or allowed. In general, do not depend on Node's fs, path, etc. unless you specifically include a polyfill or use Deno's std/node shims (but those might not all work on Deploy).

The lint rules no-node-globals and no-process-global will catch accidental use of Node globals like Buffer, global, or process. Use Web APIs or Deno APIs instead (e.g., Deno.env instead of process.env, globalThis instead of global).

Aim for zero or few dependencies—this improves your package quality and JSR score (no unreviewed third-party code).

Error Management Best Practices

  • Define custom error types if needed
  • Use clear messages
  • Throw errors when something is truly an exceptional case (like a non-OK HTTP response)
  • For expected "errors" (like API returning a 404 for a missing resource), consider whether your SDK should return undefined/null or throw—often throwing is fine, and the user can catch it
  • Document what exceptions can be thrown by your functions using @throws JSDoc tags
  • Don't leak sensitive info in error messages—for example, if the API returns an HTML error page, you might not want to include the entire page in the thrown error message (it could be huge or contain irrelevant info). Maybe include just status and statusText, or a parsed JSON error message if the API provides one.

By carefully handling errors and types, you make your SDK reliable for developers to use in production—especially in edge functions, where debugging can be harder, clear errors are a boon.


4. Optimizing for Deno Deploy and Supabase Edge Functions

Supabase Edge Functions run on Deno Deploy, a sandboxed, distributed runtime for Deno. The good news is that if your SDK runs in Deno CLI with only standard APIs, it will likely run on Deno Deploy as well. Here are best practices to ensure full compatibility and performance:

Avoid Unsupported APIs

Deno Deploy (and thus Supabase Edge) does not allow certain operations:

  • Binding to a local port (Deno.listen)
  • Spawning subprocesses (Deno.run)
  • Persistent file system access

Your SDK should not use these. Typically, an API client SDK wouldn't need them anyway. Stick to fetch, in-memory computation, and in-memory caches if needed. If you need to write temporary data (e.g., caching a token), use in-memory storage or Deno KV (Deno.openKv) which is supported on Deploy. Avoid requiring a filesystem. In short, assume a sandboxed environment.

The no-deprecated-deno-api lint rule ensures you're not using deprecated Deno APIs that might be removed in future versions.

No Long-Running Tasks

Edge functions are meant for short, quick executions. If your SDK were to do something like polling or waiting on events, it could cause issues. Each invocation of an Edge Function should complete promptly. Design SDK calls to complete once the network request finishes. If the API requires webhooks or long polling, that logic might need to be handled differently in an edge context or avoided.

Avoid Blocking Operations

The no-sync-fn-in-async-fn lint rule catches synchronous function calls inside async functions—this is important because blocking I/O in an async context stalls the event loop. For example, calling Deno.readFileSync inside an async function that could use await Deno.readFile would be flagged. In edge settings where quick responses matter, always use non-blocking, await-based calls.

The require-await rule flags async functions that don't actually await anything inside—these could just be normal functions, and removing unnecessary async saves overhead and avoids confusion.

Cold Start Performance

In Deno Deploy, when a function instance is cold-started, it has to download and compile your code (and dependencies). Keep your SDK lightweight for faster cold starts:

Lazy import heavy code: If you have any optional feature that is large (as discussed in section 1), don't load it unless needed. You can even use dynamic imports for optional modules. For example, if most endpoints are lightweight but one uses a big dependency, consider separating it. In our case, if one feature required, say, a PDF generation library, we'd keep that in a separate module—"that way it will be used much [less often] and only when specifically needed."

Minimize global work: Don't perform expensive computations or I/O at the top-level of your module (which runs on import). For example, avoid reading files or making network calls during import. Defer such work to the first function call, or make it explicit (e.g., an init function). This way, the edge function isn't doing unnecessary work on startup. A small in-memory setup (like constructing a static headers object or regex) is fine, but anything more should be reconsidered.

No console logging in production: The no-console lint rule disallows console.log/warn/error calls in library code. Uncontrolled console output can slow down applications and clutter logs. If logging is needed, consider using explicit logger hooks or returning data to the caller. SDKs should be silent by default.

Testing on Deploy: It's wise to test your SDK in a Deno Deploy environment. You can use the Supabase CLI (supabase functions serve) to test locally or deploy a test function. This will catch any compatibility issues—if you accidentally used a Node.js global like Buffer or a module that isn't available, you'll find out.

Environment Variables on Deploy

Environment variables work the same on Deploy as locally. Reading Deno.env in your SDK is fine in edge contexts—there's no --allow-env flag concern on Deploy (it's implicitly allowed for provided env vars).

Avoid Browser-Specific Globals

The no-window and no-window-prefix lint rules prevent using the window object, which is being removed in Deno 2.0. Many web APIs (like fetch, crypto) are available on globalThis—use those directly instead. This ensures your SDK works in edge environments where window isn't defined.

Mark Compatibility in JSR

When publishing, mark your package as compatible with relevant runtimes. JSR allows indicating compatibility with "deno", "node", "bun", "browser", etc. Mark at least deno runtime as compatible, and ideally two or more for a better JSR score.

In summary, write portable Deno code. Stick to Web standard APIs and Deno standard library, and your SDK will naturally be portable to the edge. The lint rules help enforce this automatically.


5. Setting Up Testing (Unit and Integration Tests)

Robust testing ensures your SDK works correctly and continues to do so as it evolves. Deno has built-in testing tools, which you should leverage.

Test Organization

Follow the convention of placing tests in files with the _test.ts suffix. The Deno style guide suggests every module should have a corresponding test module (e.g., client_test.ts for client.ts). This makes it easy to find tests and Deno will recognize them when running deno test.

Here's an example client_test.ts:

import { assertEquals } from "https://deno.land/std@0.200.0/testing/asserts.ts";
import { assertRejects } from "https://deno.land/std@0.200.0/testing/asserts.ts";
import { APIClient, APIError } from "../client.ts";

Deno.test("getUser() returns a User object on success", async () => {
  const client = new APIClient({ 
    apiKey: "test-key", 
    baseUrl: "https://api.example.com" 
  });

  // Stub fetch to simulate API response
  const originalFetch = globalThis.fetch;
  globalThis.fetch = async (_input: Request | string): Promise<Response> => {
    return new Response(
      JSON.stringify({ id: "123", name: "Alice", email: "alice@example.com" }), 
      { status: 200 }
    );
  };

  const user = await client.getUser("123");
  assertEquals(user.name, "Alice");
  assertEquals(user.id, "123");

  globalThis.fetch = originalFetch; // restore
});

Deno.test("getUser() throws APIError on HTTP error", async () => {
  const client = new APIClient({ 
    apiKey: "bad-key", 
    baseUrl: "https://api.example.com" 
  });

  const originalFetch = globalThis.fetch;
  globalThis.fetch = async (): Promise<Response> => {
    return new Response("Unauthorized", { status: 401, statusText: "Unauthorized" });
  };

  await assertRejects(
    async () => {
      await client.getUser("999");
    },
    APIError,
    "401"
  );

  globalThis.fetch = originalFetch;
});
Enter fullscreen mode Exit fullscreen mode

This uses Deno's Deno.test function to define test cases. We use assertEquals to check returned data, and assertRejects to verify that an APIError is thrown on a bad response. We monkey-patch globalThis.fetch to simulate API responses, which is a simple way to test without real network calls. (Alternatively, you could allow injecting a custom fetch function into your client for testing, but patching works in a pinch.)

Notice we restore globalThis.fetch after each test to avoid side effects. Deno tests run in a single process by default, so cleanup is important.

Integration Tests

If you want to test against the real API (integration tests), you can mark those tests with a permission requirement and perhaps a special name:

Deno.test({
  name: "getUser() against live API",
  permissions: { net: true, env: true }
}, async () => {
  const apiKey = Deno.env.get("MYAPI_KEY");
  if (!apiKey) {
    console.warn("Skipping live API test because MYAPI_KEY is not set");
    return;
  }
  const client = new APIClient({ apiKey });
  const user = await client.getUser("123");
  assertEquals(user.id, "123");
});
Enter fullscreen mode Exit fullscreen mode

Here we used permissions: { net: true, env: true } to allow network and environment access. Running this test would require deno test --allow-net --allow-env. Be cautious with live tests—they might fail due to external factors (network issues, API down, rate limits). Consider separating them from unit tests.

Testing Documentation Examples

Use deno test --doc to type-check and run examples in your JSDoc comments. This ensures your documentation stays accurate and up-to-date:

/**
 * Adds two numbers together.
 * @param a - First number
 * @param b - Second number
 * @returns The sum of a and b
 * @example
 * ```

ts
 * import { add } from "./math.ts";
 * const result = add(1, 2);
 * console.log(result); // 3
 *

Enter fullscreen mode Exit fullscreen mode

*/
export function add(a: number, b: number): number {
return a + b;
}




Running `deno test --doc` will execute that example and verify it works.

### Running Tests

Deno's test runner will automatically discover tests and run them in parallel:



```bash
deno test
deno test --coverage  # for coverage reports
deno test --doc       # also test documentation examples
Enter fullscreen mode Exit fullscreen mode

Aim to cover the main code paths: success, error, edge cases like invalid inputs.

Continuous Testing

Automate tests with CI (GitHub Actions). Run deno lint, deno fmt --check, and deno test on each push to prevent regressions and maintain quality.

Summary

  • Write unit tests for logic (stubbing external calls as needed)
  • Write integration tests for real API calls (guarded by permissions)
  • Write doc tests to verify documentation examples work
  • Keep tests alongside code for easy maintenance
  • Ensure tests don't leak secrets
  • Use deno lint and deno fmt to maintain code consistency

A well-tested SDK inspires confidence in users and performs reliably in critical edge function deployments.


6. Example Usage in a Supabase Edge Function

To illustrate how your SDK would be used in context, let's walk through an example of using it inside a Supabase Edge Function.

Scenario

Imagine an Edge Function that receives a request (maybe a webhook or HTTP call) and needs to fetch some data from the third-party API via our SDK, then return a JSON response.

First, in your function's code (say functions/fetch_user_data/index.ts in the Supabase project), import the SDK. If you published to deno.land/x:

import { APIClient } from "https://deno.land/x/my_api_sdk@0.1.0/mod.ts";
Enter fullscreen mode Exit fullscreen mode

If published to JSR, and you have an import map or are using the CLI's support:

import { APIClient } from "@yourname/my-api-sdk";
Enter fullscreen mode Exit fullscreen mode

For simplicity, let's assume a URL import from deno.land/x for this example:

// Follow Supabase Edge Function setup guidelines for the Deno runtime.

// Import the SDK (replace URL with actual registry URL and version)
import { APIClient } from "https://deno.land/x/my_api_sdk@0.1.0/mod.ts";

// Import types separately using type-only import
import type { User } from "https://deno.land/x/my_api_sdk@0.1.0/mod.ts";

// Initialize the client with the API key from environment (Supabase secret)
const apiKey = Deno.env.get("MYAPI_KEY");
if (!apiKey) {
  throw new Error("Missing MYAPI_KEY in environment");
}
const client = new APIClient({ apiKey });

// Supabase Edge Function handler
export default async function handler(req: Request): Promise<Response> {
  // Parse request
  let userId: string;
  try {
    const body: unknown = await req.json();
    if (typeof body === "object" && body !== null && "id" in body) {
      userId = String((body as { id: unknown }).id);
    } else {
      throw new Error("Missing id field");
    }
  } catch {
    return new Response(
      JSON.stringify({ error: "Invalid request body" }), 
      { status: 400, headers: { "Content-Type": "application/json" } }
    );
  }

  try {
    const user: User = await client.getUser(userId);
    return new Response(JSON.stringify(user), {
      headers: { "Content-Type": "application/json" },
    });
  } catch (err) {
    if (err instanceof Error && err.name === "APIError") {
      const status = (err as { status?: number }).status ?? 502;
      return new Response(
        JSON.stringify({ error: err.message }), 
        { status, headers: { "Content-Type": "application/json" } }
      );
    } else {
      // In production, you might want a proper logger instead of console
      return new Response(
        JSON.stringify({ error: "Internal Server Error" }), 
        { status: 500, headers: { "Content-Type": "application/json" } }
      );
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Key Points

  • We retrieve the API key via Deno.env.get("MYAPI_KEY"). In Supabase, set this secret in your project before deploying. If not set, we throw at the top—causing the function to fail to boot (which is better than running without a key).
  • We create an APIClient instance outside the handler. This is good practice because it can be reused across requests in the same function instance (edge function instances are reused for multiple invocations). This avoids re-initializing on every request.
  • The handler function is exported as default, following Supabase's expectation for Edge Functions. It accepts a Request and returns a Response.
  • We parse the incoming request JSON to get a userId with proper type narrowing (no any).
  • We use the SDK (client.getUser(userId)) to fetch data from the third-party API.
  • We handle errors appropriately, returning proper HTTP status codes.

This pattern works for any API calls your function needs. The SDK usage on the edge is identical to usage in a regular Deno script—which is the beauty of writing it in Deno. There's no special "edge mode" needed.

Dependency Management in Edge Functions

If your SDK is published to JSR or deno.land/x, you might prefer using an import map or the Supabase CLI's dependency management. Supabase Edge Functions allow third-party code via URL references or CLI bundling.

Example Outcome

With the above function deployed, you can call it via HTTP and it will use the SDK to fetch from the external API and return the result. Supabase provides the scheduling and HTTP interface; your SDK provides the business logic.

When writing documentation for your SDK, include a section "Using with Supabase Edge Functions" with a snippet like the above. It helps users understand setup requirements.


7. Publishing the SDK to JSR

After building and testing your SDK, publish it so others can use it. Deno's JavaScript Registry (JSR) is the recommended way. You can also publish to deno.land/x for additional visibility.

Publishing via JSR

JSR is a package registry for JavaScript/TypeScript, integrated with the Deno ecosystem.

Prepare package metadata: In your deno.json or a separate jsr.json:

  • name: Package name with scope (e.g., @yourname/my-api-sdk)
  • version: Semantic version (e.g., 0.1.0)
  • exports: Entry point(s), usually ./mod.ts
  • description: One-liner description
  • Optional: license, homepage, repository fields

Example jsr.json:

{
  "name": "@yourname/my-api-sdk",
  "version": "1.0.0",
  "description": "Deno SDK for My API, providing easy REST calls.",
  "license": "MIT",
  "exports": "./mod.ts"
}
Enter fullscreen mode Exit fullscreen mode

Login and publish:

deno publish
Enter fullscreen mode Exit fullscreen mode

This prompts authentication (likely via GitHub) and uploads your package. Alternatively, npx jsr publish works if you prefer npm's toolchain. Ensure you're publishing from a clean git state matching the version.

On publish, JSR performs checks: type-checking, ensuring no "slow types", generating documentation from JSDoc, and computing a quality score.

No "Slow Types"

JSR enforces that you avoid complex or inferred types that slow down consumers' type-checking. The no-slow-types lint rule (included in the jsr tag) catches these issues early. If you have explicit return types on all exported functions (enforced by explicit-function-return-type), you'll typically pass this check.

If JSR flags slow types, fix them by adding explicit type annotations. You can publish with --allow-slow-types to override, but this is discouraged—it negatively impacts package quality.

Documentation Generation

JSR automatically generates HTML docs from your code comments and README. To ensure quality:

Provide a module doc comment at the top of mod.ts:

/**
 * @module
 * SDK for interacting with the Example API.
 * 
 * ## Basic Usage
 * 
 * ```

ts
 * import { APIClient } from "@yourname/my-api-sdk";
 * 
 * const client = new APIClient({ apiKey: "your-key" });
 * const user = await client.getUser("123");
 *

Enter fullscreen mode Exit fullscreen mode

*/

export { APIClient } from "./client.ts";
export type { User, CreateUserInput } from "./types.ts";




**Use JSDoc comments** for all exported functions, classes, and interfaces with `@param`, `@returns`, `@throws`, and `@example` tags.

**Run documentation linting:**



```bash
deno doc --lint mod.ts
Enter fullscreen mode Exit fullscreen mode

This catches missing JSDoc for exported symbols or broken example links before publishing.

Document all entry points: If you have submodules meant to be imported, ensure those have module docs too.

JSR Score Factors

JSR gives your package a score based on quality metrics. To maximize it:

  • README or module doc present: Have a README.md or @module JSDoc
  • Examples included: Provide usage examples in README or @example JSDoc
  • Docs for all public APIs: Every exported function/type should have documentation
  • No slow types: Pass the slow types check
  • Description provided: Include package description in metadata
  • Runtime compatibility: Mark at least one runtime (ideally 2+) as compatible
  • Provenance: Publish from CI with Sigstore signing for verification

View the score breakdown on your package's JSR page and aim for 100%.

Immutability

Once published, a version is immutable. You can't override 0.1.0 with a fix—publish a new version. Double-check everything before publishing.

Publishing to deno.land/x

deno.land/x fetches from GitHub releases:

  1. Ensure code is in a public GitHub repo
  2. Submit your repo at https://deno.land/x
  3. Create a git tag (e.g., v1.0.0) and push it
  4. Users import via https://deno.land/x/<repo>@v1.0.0/mod.ts

Keep version numbers in sync if using both JSR and deno.land/x.

JSR vs deno.land/x

JSR is more feature-rich with scoring, native TypeScript support, and npm-compatible consumption. deno.land/x is still useful for URL imports. Use both if you like—JSR gives extra quality validation.

After Publishing

Test installation as a user:

deno add @yourname/my-api-sdk
Enter fullscreen mode Exit fullscreen mode

Check the generated documentation on jsr.io for accuracy.


8. Documentation, Maintenance, and Versioning

Keeping your SDK high-quality is ongoing work.

Documentation

README.md: Include:

  • Description of the API
  • How to import the SDK
  • Basic usage examples
  • Links to full documentation

This shows up on deno.land/x, GitHub, and JSR.

JSDoc comments: Every exported function, class, and module should have documentation. Write in plain language, explain the what and why. Use Markdown in JSDoc for richer formatting.

Autogenerate docs: Use deno doc --json > docs.json for custom documentation sites, or rely on JSR's auto-generated docs.

Examples and tutorials: Include an /examples directory with sample scripts.

Maintenance

Testing on new Deno versions: Test your SDK on new Deno releases. Supabase Edge Runtime tracks Deno versions (currently compatible with Deno v2.1.4).

Continuous Integration: Set up CI to run deno lint, deno fmt --check, deno doc --lint, and deno test on each commit/PR.

Formatting and linting: Enforce consistent style with deno fmt and deno lint.

Dependency updates: Periodically update pinned dependencies for improvements or security fixes.

Issue tracking: Treat user feedback seriously. Consider writing a CONTRIBUTING.md for guidelines.

Supabase Edge considerations: Subscribe to Supabase changelogs or Deno Deploy announcements for runtime updates.

Versioning

Use Semantic Versioning (SemVer): MAJOR.MINOR.PATCH.

For initial development, use 0.x versions. Once stable, hit 1.0.0:

  • MAJOR: Breaking API changes
  • MINOR: New features (backwards compatible)
  • PATCH: Bug fixes

Communicate changes: Maintain a CHANGELOG.md listing changes per version.

Deprecation strategy: If the upstream API changes, plan your SDK update. Document which API version your SDK targets.

Testing versions: Test that published packages work before announcing releases.


9. Linting Configuration for Production-Ready SDKs

Building a high-quality Deno SDK requires strict linting rules that enforce best practices. Proper linting produces idiomatic, ergonomic code and maximizes your JSR quality score. Here's a comprehensive lint configuration with explanations.

Base Configuration

Start with Deno's recommended ruleset, layer on the JSR ruleset, then add specific rules for stricter standards:

{
  "lint": {
    "rules": {
      "tags": ["recommended", "jsr"],
      "include": [
        "explicit-function-return-type",
        "explicit-module-boundary-types",
        "camelcase",
        "single-var-declarator",
        "no-console"
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This enables all recommended rules plus JSR-specific rules, and explicitly includes extra rules for explicit typing, naming, and clean output.

Idiomatic Deno & Cross-Runtime Compatibility

These rules ensure your SDK uses Deno's standard APIs and web-standard globals:

Rule Purpose
no-deprecated-deno-api Warns against deprecated Deno APIs for forward compatibility
no-node-globals Disallows Node.js globals (Buffer, global, etc.)
no-process-global Disallows process global—use Deno.env instead
no-window Disallows window object (being removed in Deno 2.0)
no-window-prefix Disallows window. prefix—use globalThis
no-var Forbids var in favor of let/const
prefer-const Recommends const for never-reassigned variables
no-unversioned-import Requires explicit version tags in imports
no-sloppy-imports Requires explicit file paths with extensions

Optional: no-external-import disallows any external URL imports if you vendor all dependencies.

Strict TypeScript Types and Safety

Strong typing is essential for a reliable SDK:

Rule Purpose
no-explicit-any Disallows any type entirely—use unknown instead
no-empty-interface Disallows empty interfaces (use Record<string, never> or remove)
no-inferrable-types Prohibits redundant type declarations TypeScript can infer
explicit-function-return-type Requires explicit return types on all functions
explicit-module-boundary-types Requires explicit types on exported symbols
no-non-null-assertion Disallows ! operator—handle undefined safely
no-non-null-asserted-optional-chain Disallows ! after optional chain
ban-types Forbids problematic types like {}, Function, Object
use-isnan Requires Number.isNaN() instead of === NaN
eqeqeq Enforces strict equality ===/!==
valid-typeof Validates typeof comparison strings

Note: Deno doesn't currently have a no-floating-promises rule. As best practice, manually ensure all promises are awaited or handled.

JSR Compliance and Documentation

The jsr tag enables these crucial rules:

Rule Purpose
no-slow-types Ensures types are explicit or simple enough for fast type-checking
verbatim-module-syntax Requires import type for type-only imports

Documentation practices (not enforced by lint, but crucial):

  • Run deno doc --lint to catch missing JSDoc
  • Ensure every export has a clear JSDoc comment
  • Use @param, @returns, @throws, @example tags
  • Run deno test --doc to verify documentation examples work

Edge Performance Rules

Rule Purpose
no-sync-fn-in-async-fn Catches blocking calls in async functions
require-await Flags async functions that don't await anything
no-console Disallows console logging in library code

Code Style and Maintainability

Rule Purpose
camelcase Enforces camelCase naming
single-var-declarator One variable per declaration statement
ban-untagged-todo Requires TODOs to have owner or issue number
ban-unused-ignore Warns about unnecessary lint ignore comments
ban-ts-comment Requires explanation for @ts-ignore comments
no-useless-rename Disallows pointless import/export renames
adjacent-overload-signatures Groups function overloads together

Optional: prefer-ascii ensures code uses only ASCII characters (useful for preventing homoglyph attacks).

Complete Configuration Example

Here's a full deno.json configuration for a production-ready SDK:

{
  "compilerOptions": {
    "strict": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true
  },
  "lint": {
    "rules": {
      "tags": ["recommended", "jsr"],
      "include": [
        "explicit-function-return-type",
        "explicit-module-boundary-types",
        "camelcase",
        "single-var-declarator",
        "no-console",
        "ban-untagged-todo"
      ]
    }
  },
  "fmt": {
    "indentWidth": 2,
    "lineWidth": 100,
    "singleQuote": false,
    "proseWrap": "preserve"
  },
  "test": {
    "include": ["**/*_test.ts"]
  },
  "package": {
    "name": "@yourname/my-api-sdk",
    "version": "1.0.0",
    "description": "Deno SDK for My API",
    "license": "MIT",
    "exports": "./mod.ts"
  }
}
Enter fullscreen mode Exit fullscreen mode

Running Lint

# Run linter
deno lint

# Run linter with auto-fix for fixable issues
deno lint --fix

# Check documentation
deno doc --lint mod.ts

# Format code
deno fmt

# Check formatting without changing files
deno fmt --check
Enter fullscreen mode Exit fullscreen mode

CI Pipeline Example

name: CI
on: [push, pull_request]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: denoland/setup-deno@v1
        with:
          deno-version: v2.x

      - name: Check formatting
        run: deno fmt --check

      - name: Lint
        run: deno lint

      - name: Check documentation
        run: deno doc --lint mod.ts

      - name: Test
        run: deno test --allow-env --allow-net

      - name: Test documentation examples
        run: deno test --doc
Enter fullscreen mode Exit fullscreen mode

Summary

By applying these lint rules, your SDK achieves:

  • Idiomatic Deno code: No Node globals, no deprecated APIs, no browser-only globals
  • Full type safety: No any, explicit APIs, complete type declarations
  • Edge optimization: Non-blocking, minimal overhead, no unnecessary logging
  • Modern patterns: ESM imports, const/let, proper module syntax
  • Consistent style: Enforced naming, formatting, and documentation

These rules catch issues at the static analysis stage, leading to fewer bugs and a higher-quality SDK that earns a top JSR score.


Conclusion

You've now built a Deno SDK for a RESTful API tailored for Supabase Edge Functions. We covered:

  • Planning a clean project structure with mod.ts entry point
  • Handling API keys safely with environment variables
  • Writing idiomatic TypeScript with strong types
  • Ensuring Deno Deploy compatibility
  • Testing thoroughly with unit, integration, and doc tests
  • Publishing with JSR best practices
  • Configuring linting for production quality

Such an SDK provides a great developer experience: it abstracts HTTP details behind easy-to-use functions, runs anywhere Deno runs (locally or at the edge), and is packaged and documented professionally. By hand-crafting the SDK with strict linting, you avoided code generation pitfalls and created a module that feels native to Deno.

Now developers can import your SDK with a single line and use it inside their Supabase Edge Functions (or any Deno app) to integrate with third-party APIs in a secure and efficient manner.

Happy coding, and may your JSR score be 100%! 🎉

Top comments (0)