How I built 700+ developer tools as a static site with Next.js, Zod, and Claude Code

# javascript# typescript# nextjs# webdev
How I built 700+ developer tools as a static site with Next.js, Zod, and Claude CodeKranthi Kumar Muppala

Every developer has a handful of bookmarked online tools - a JSON formatter here, a Base64 encoder...

Every developer has a handful of bookmarked online tools - a JSON formatter here, a Base64 encoder there, a regex tester somewhere else. Each one has ads, signup walls, or sends your data to a server. I wanted them all in one place, running entirely in the browser with zero server dependency.

The result is utils.live: 734 tools across 34 categories, statically generated and served from Cloudflare Pages for $0/month. The entire codebase was built using Claude Code.

Here's how the architecture works.

The core abstraction: defineTool()

Every tool in the system is a stateless pure function wrapped in a schema-validated definition. The defineTool() function takes four things: metadata, a Zod input schema, a Zod output schema, and an execute function:

export const jsonFormatter = defineTool({
  meta: {
    id: "json/formatter",
    name: "JSON Formatter",
    description: "Format and prettify JSON with configurable indentation",
    category: "json",
    keywords: ["json", "format", "prettify", "beautify", "indent"],
  },
  inputSchema: z.object({
    input: z.string().describe("JSON string to format"),
  }),
  outputSchema: z.object({
    output: z.string().describe("Formatted JSON string"),
  }),
  optionsSchema: z.object({
    indent: z.number().int().min(0).max(8).default(2),
    sortKeys: z.boolean().default(false),
  }),
  execute: (input, options) => {
    const parsed = JSON.parse(input.input);
    return {
      output: JSON.stringify(parsed, null, options?.indent ?? 2),
    };
  },
});
Enter fullscreen mode Exit fullscreen mode

This pattern has a few properties I find useful.

Type safety is end-to-end. The generic type parameters on defineTool() infer input/output types from the Zod schemas, so the execute function's parameters are fully typed without any explicit type annotations.

The schemas serve double duty. At execution time, they validate inputs. At build time, they're converted to JSON Schema (via Zod's toJSONSchema()) and used to generate the UI —form fields, editor language selection, file upload settings —all without any per-tool UI code.

Tools are pure. No side effects, no DOM access, no network calls. The tools package has zero React or browser dependencies. It's pure TypeScript functions all the way down.

The execution pipeline

The executor wraps every tool call with validation, size limits, timeout enforcement, and metadata collection:

export async function executeTool(tool, input, options) {
  // 1. Validate input against Zod schema
  const inputResult = validateInput(tool.inputSchema, input);
  if (!inputResult.success) return failure(inputResult.error);

  // 2. Validate options (applies defaults from schema)
  const optionsResult = validateOptions(tool.optionsSchema, options);
  if (!optionsResult.success) return failure(optionsResult.error);

  // 3. Reject inputs > 5MB to prevent memory exhaustion
  if (getByteSize(input) > 5 * 1024 * 1024)
    return failure("Input too large");

  // 4. Execute with 5-second timeout via Promise.race
  const output = await Promise.race([
    Promise.resolve(tool.execute(inputResult.data, optionsResult.data)),
    createTimeoutPromise(5000),
  ]);

  // 5. Return result with execution metadata
  return success(output, { executionTimeMs, inputSizeBytes, outputSizeBytes });
}
Enter fullscreen mode Exit fullscreen mode

The result type is a discriminated union (ToolSuccess<T> | ToolFailure), so consumers always handle both paths. Every execution produces metadata: timing in milliseconds, input/output byte sizes, and a timestamp.

Auto-detecting the right UI layout

With 734 tools, writing per-tool UI configuration would be absurd. Instead, the system infers everything from tool metadata and schema shapes.

There are three layout variants:

  • Standard: single input editor -> output editor (most tools)
  • Diff: two input editors -> diff view (comparators)
  • Generator: form-based inputs -> output (random generators, calculators)

Detection is heuristic. It looks at the tool name and input schema structure:

function getToolVariant(tool, inputSchema, diffPatterns) {
  const name = tool.name.toLowerCase();

  // Name contains "diff", "compare", "difference" -> diff layout
  if (diffPatterns.some(p => name.includes(p))) return "diff";

  // Name contains "generator", "random", "lorem" -> generator layout
  if (GENERATOR_PATTERNS.some(p => name.includes(p))) return "generator";

  // All input fields have defaults -> form-based (generator)
  if (allFieldsOptionalOrDefaulted(inputSchema)) return "generator";

  return "standard";
}
Enter fullscreen mode Exit fullscreen mode

The output renderer follows the same pattern. Category and tool name determine which of the 10 render modes to use:

Category/Pattern Renderer What it does
json, yaml, css code Monaco editor with syntax highlighting
csv table Sortable, filterable data table
html html Sandboxed iframe preview
markdown markdown Rendered markdown
color color Color swatch display
"palette", "scheme" in name color-palette Grid of color swatches
"diff", "compare" in name diff Side-by-side diff viewer
image tools image Image preview with zoom
diagram tools diagram Mermaid diagram renderer

All renderers are lazy-loaded via next/dynamic with ssr: false, so the initial bundle only includes the renderer a specific tool needs.

Converter tools get special handling: a tool ID like json-to-yaml is parsed to detect the target format, and the output editor automatically gets YAML syntax highlighting.

Schema-driven form generation

The options panel auto-generates form fields from the JSON Schema:

  • boolean -> toggle switch
  • enum -> searchable select dropdown
  • number with min/max -> range slider
  • number without bounds -> number input
  • string -> text input

Property names are converted from camelCase to title case automatically (sortKeys -> "Sort Keys"). The generator layout does the same thing for input schemas, turning structured Zod definitions into interactive forms without any hand-written UI.

Static generation of 730+ pages

Each tool gets its own URL (/tools/{category}/{tool-slug}) and is pre-rendered at build time:

export function generateStaticParams() {
  const tools = getAllToolCards(); // Returns all 734 tools
  return tools.map(tool => {
    const [category, slug] = tool.id.split("/");
    return { category, tool: slug };
  });
}
Enter fullscreen mode Exit fullscreen mode

The getTool() function bridges the tool system and the Next.js app. It converts Zod schemas to JSON Schema for the client-side form renderer, using React's cache() to avoid redundant conversions:

export const getTool = cache((category, toolSlug) => {
  const tool = getToolById(`${category}/${toolSlug}`);
  const ui = getToolUIConfig(tool.meta); // Inferred UI config

  return {
    meta: tool.meta,
    ui,
    inputSchema: convertSchema(tool.inputSchema),   // Zod -> JSON Schema
    optionsSchema: convertSchema(tool.optionsSchema),
    outputSchema: convertSchema(tool.outputSchema),
    examples: tool.meta.examples ?? [],
  };
});
Enter fullscreen mode Exit fullscreen mode

The build produces ~7,800 static files, well under Cloudflare Pages' 20,000 file limit.

Registration and the global registry

All 734 tools are registered via side-effect imports in a single file:

import { jsonFormatter, jsonValidator, jsonMinify } from "./json";
import { yamlFormatter, yamlToJson } from "./yaml";
// ... 30+ more category imports

const ALL_TOOLS = [
  jsonFormatter, jsonValidator, jsonMinify,
  yamlFormatter, yamlToJson,
  // ... 734 tools total
];

export function registerAllTools() {
  for (const tool of ALL_TOOLS) {
    globalRegistry.registerTool(tool);
  }
}

registerAllTools(); // Auto-register on import
Enter fullscreen mode Exit fullscreen mode

The registry validates tool IDs against the pattern ^[a-z]+\/[a-z0-9-]+$ and checks that the category portion exists in the 34 predefined categories. Duplicates throw.

This file is 1,700 lines long. It works, but an auto-discovery pattern (glob imports) would be cleaner. It's on the list.

Client-side execution with auto-execute

For the standard layout, tools execute automatically as you type with a 300ms debounce:

useEffect(() => {
  if (!input) return;

  const timer = setTimeout(async () => {
    const { executeTool, getToolById } = await import("@utils-live/tools");
    const tool = getToolById(toolId);
    const result = await executeTool(tool, { input }, options);
    setOutput(result);
  }, 300);

  return () => clearTimeout(timer);
}, [input, options]);
Enter fullscreen mode Exit fullscreen mode

The tools package itself is dynamically imported to keep it out of the initial page bundle. It only loads when a user actually visits a tool page.

Fuzzy search across 734 tools

The global search (Cmd+K) uses a weighted multi-tier scoring system:

Exact name match      -> 1.0
Prefix match          -> 0.9 + length bonus
Contains match        -> 0.6 + position bonus
Word boundary match   -> 0.7
Fuzzy (Levenshtein)   -> 0.5 if similarity > 0.5
Enter fullscreen mode Exit fullscreen mode

Scores are weighted by field: name (1.0x), keywords (0.8x), description (0.7x). Searching "base64" instantly finds "Base64 Encode" over a tool whose description merely mentions base64.

Recent tools are tracked in localStorage for quick access.

Building with Claude Code

The entire codebase was built using Claude Code. A few patterns that worked well:

Tool generation is mechanical. Once the defineTool() pattern was established, adding new tools is formulaic: define schemas, write the execute function, add a test, register it. Claude Code handles this efficiently because each tool is self-contained with no cross-dependencies.

Parallel agents for bulk operations. Tasks like "update all 34 category descriptions" or "remove open-source references from every file" benefit from launching 3 agents working on independent file sets. Wall-clock time drops significantly.

Tests as specification. The tools package enforces 100% test coverage thresholds. Writing the test first and having Claude Code implement the tool to pass it produced more reliable results than writing the implementation first.

Schema-first design. Describing what a tool does via Zod schemas before writing the logic turned out to be a natural fit for AI-assisted development. The schemas constrain the implementation enough that the generated code is usually correct on the first pass.

Deployment

The entire site deploys as a static site on Cloudflare Pages' free tier. No database, no server, no containers. Git push triggers a rebuild.

pnpm turbo build
  -> packages/tools builds first (TypeScript -> dist/)
  -> apps/web builds second (Next.js static export -> out/)
  -> ~7,800 files deployed to Cloudflare's edge network
Enter fullscreen mode Exit fullscreen mode

Total hosting cost: $0/month.

What I'd do differently

Tool descriptions are too short for SEO. Most are 30-60 characters when meta descriptions should be 150-160. Working on auto-generating longer descriptions from the existing metadata.

No OG images. Static export can't use Next.js's edge-runtime image generation. Planning to add build-time OG image generation with satori + resvg.

The 1,700-line registration file. A single array of 734 imports works but isn't elegant. Auto-discovery via glob imports would be better.

Should have started with fewer, more polished tools. Having 734 tools is impressive as a number but many could use better examples and documentation. Depth over breadth would have been the smarter launch strategy.


If you want to try it: utils.live

The tools I use most: JSON Formatter, Base64 Encoder, JWT Decoder, Regex Tester.