Full-Stack Engineering
Next.js API Route Design Patterns for Production
TL;DR
Every API I build with Next.js follows the same set of patterns: Zod for request validation, structured error responses, authentication middleware that composes cleanly, rate limiting per endpoint, cursor-based pagination, and proper webhook verification. These are not theoretical patterns from a blog post I read. They come from shipping EuroParts Lanka, FreshMart, and half a dozen other production apps where a malformed request at 2 AM means real money lost. This article is the reference I wish existed when I started building Next.js API routes that needed to survive actual traffic.
Route Handlers vs API Routes
If you are still using the pages/api directory, you are working with the legacy API Routes system. Next.js App Router introduced Route Handlers — file-based endpoints that live inside the app directory and export named functions for each HTTP method. This is the only pattern I use now, and it is the only one I recommend.
The difference is not just cosmetic. Route Handlers give you direct access to the Web Request and Response APIs. They support streaming. They run in both Node.js and Edge runtimes. They sit alongside your page routes, which means your API structure mirrors your application structure. No more mental gymnastics mapping /pages/api/products/[id].ts to /app/products/[id]/page.tsx.
Here is the simplest Route Handler:
// app/api/products/route.ts
import { NextResponse } from "next/server";
export async function GET() {
const products = await db.product.findMany();
return NextResponse.json(products);
}That is fine for a demo. It is not fine for production. There is no validation, no error handling, no authentication, no rate limiting, and no pagination. The rest of this article is about fixing all of that.
The naming convention matters. Each HTTP method gets its own exported function — GET, POST, PUT, PATCH, DELETE. One file per route segment. Dynamic segments use folder names like [id]. If you need both a collection endpoint and a single-resource endpoint, you get two files:
app/api/products/route.ts → GET (list), POST (create)
app/api/products/[id]/route.ts → GET (single), PUT (update), DELETE (remove)This is clean. This scales. Every project I touch follows this convention.
Request Validation with Zod
I validate every incoming request with Zod. Not some requests. Every request. If you are parsing request.json() and trusting whatever comes back, you are building a vulnerability, not an API.
Here is my base pattern for a POST endpoint:
// lib/validations/product.ts
import { z } from "zod";
export const createProductSchema = z.object({
name: z.string().min(1, "Name is required").max(200),
price: z.number().positive("Price must be positive"),
sku: z.string().regex(/^[A-Z]{2,4}-\d{4,8}$/, "Invalid SKU format"),
categoryId: z.string().uuid("Invalid category ID"),
description: z.string().max(2000).optional(),
});
export type CreateProductInput = z.infer<typeof createProductSchema>;And the Route Handler that uses it:
// app/api/products/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createProductSchema } from "@/lib/validations/product";
import { ApiError, handleApiError } from "@/lib/api/errors";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const validated = createProductSchema.safeParse(body);
if (!validated.success) {
return NextResponse.json(
{
code: "VALIDATION_ERROR",
message: "Invalid request body",
details: validated.error.flatten().fieldErrors,
},
{ status: 400 }
);
}
const product = await db.product.create({
data: validated.data,
});
return NextResponse.json(product, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}A few things to notice. I use safeParse, not parse. The parse method throws on failure, which means you need a try-catch just for validation. safeParse returns a discriminated union — either { success: true, data } or { success: false, error }. Much cleaner control flow.
The error response uses flatten().fieldErrors to return a structured object that maps field names to error messages. Your frontend gets something like:
{
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": {
"price": ["Price must be positive"],
"sku": ["Invalid SKU format"]
}
}That is directly usable by a form library like react-hook-form. No parsing. No guessing.
For query parameters on GET endpoints, I validate those too:
const listProductsSchema = z.object({
cursor: z.string().uuid().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
category: z.string().uuid().optional(),
search: z.string().max(100).optional(),
});
export async function GET(request: NextRequest) {
const params = Object.fromEntries(request.nextUrl.searchParams);
const validated = listProductsSchema.safeParse(params);
if (!validated.success) {
return NextResponse.json(
{
code: "VALIDATION_ERROR",
message: "Invalid query parameters",
details: validated.error.flatten().fieldErrors,
},
{ status: 400 }
);
}
// validated.data is fully typed and has defaults applied
}The z.coerce.number() is critical here. Query parameters arrive as strings. Coerce tells Zod to convert the string to a number before validating. Without it, every numeric query param fails validation.
On EuroParts Lanka, I have 47 API endpoints. Every single one validates input with Zod. The total number of runtime type errors that have reached production from invalid request bodies: zero.
Authentication Middleware
Next.js does not have a built-in middleware chain for Route Handlers. You have to build your own. I have tried several approaches — higher-order functions, wrapper utilities, middleware arrays — and settled on a pattern that is simple, composable, and type-safe.
// lib/api/auth.ts
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
type AuthenticatedHandler = (
request: NextRequest,
context: { params: Record<string, string> },
session: Session
) => Promise<NextResponse>;
export function withAuth(handler: AuthenticatedHandler) {
return async (
request: NextRequest,
context: { params: Record<string, string> }
) => {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json(
{ code: "UNAUTHORIZED", message: "Authentication required" },
{ status: 401 }
);
}
return handler(request, context, session);
};
}
export function withRole(role: string, handler: AuthenticatedHandler) {
return withAuth(async (request, context, session) => {
if (session.user.role !== role) {
return NextResponse.json(
{ code: "FORBIDDEN", message: "Insufficient permissions" },
{ status: 403 }
);
}
return handler(request, context, session);
});
}Using it in a Route Handler:
// app/api/admin/products/route.ts
import { withRole } from "@/lib/api/auth";
export const POST = withRole("admin", async (request, context, session) => {
const body = await request.json();
// session.user is fully typed and guaranteed to exist
// session.user.role is guaranteed to be "admin"
const product = await db.product.create({
data: {
...validated.data,
createdBy: session.user.id,
},
});
return NextResponse.json(product, { status: 201 });
});The beauty of this pattern is composition. withRole wraps withAuth. You can add more layers — withRateLimit, withValidation, withLogging — and they stack cleanly. The session flows through as a parameter, not a separate fetch call inside the handler.
For FreshMart, I extended this with a withApiKey wrapper for machine-to-machine communication:
export function withApiKey(handler: ApiKeyHandler) {
return async (request: NextRequest, context: { params: Record<string, string> }) => {
const apiKey = request.headers.get("x-api-key");
if (!apiKey) {
return NextResponse.json(
{ code: "UNAUTHORIZED", message: "API key required" },
{ status: 401 }
);
}
const keyRecord = await db.apiKey.findUnique({
where: { key: apiKey, revokedAt: null },
include: { organization: true },
});
if (!keyRecord) {
return NextResponse.json(
{ code: "UNAUTHORIZED", message: "Invalid API key" },
{ status: 401 }
);
}
return handler(request, context, keyRecord);
};
}Same pattern. Same composition model. The middleware handles the auth concern, the handler handles the business logic. Single responsibility all the way down.
Error Handling Pattern
Every API needs a consistent error shape. I have seen too many Next.js projects where one endpoint returns { error: "Not found" }, another returns { message: "Something went wrong" }, and a third returns a raw string. Your frontend developers — even when that is you — deserve better.
Here is the error handling module I use on every project:
// lib/api/errors.ts
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { ZodError } from "zod";
interface ApiErrorResponse {
code: string;
message: string;
details?: Record<string, string[]> | string;
}
export class ApiError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
public details?: Record<string, string[]> | string
) {
super(message);
this.name = "ApiError";
}
static badRequest(message: string, details?: Record<string, string[]>) {
return new ApiError(400, "BAD_REQUEST", message, details);
}
static unauthorized(message = "Authentication required") {
return new ApiError(401, "UNAUTHORIZED", message);
}
static forbidden(message = "Insufficient permissions") {
return new ApiError(403, "FORBIDDEN", message);
}
static notFound(resource = "Resource") {
return new ApiError(404, "NOT_FOUND", `${resource} not found`);
}
static conflict(message: string) {
return new ApiError(409, "CONFLICT", message);
}
static tooManyRequests(retryAfter: number) {
return new ApiError(429, "RATE_LIMITED", "Too many requests", `Retry after ${retryAfter}s`);
}
}
export function handleApiError(error: unknown): NextResponse<ApiErrorResponse> {
// Known application errors
if (error instanceof ApiError) {
return NextResponse.json(
{ code: error.code, message: error.message, details: error.details },
{ status: error.statusCode }
);
}
// Zod validation errors
if (error instanceof ZodError) {
return NextResponse.json(
{
code: "VALIDATION_ERROR",
message: "Invalid request data",
details: error.flatten().fieldErrors,
},
{ status: 400 }
);
}
// Prisma known errors
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2002") {
return NextResponse.json(
{ code: "CONFLICT", message: "A record with this value already exists" },
{ status: 409 }
);
}
if (error.code === "P2025") {
return NextResponse.json(
{ code: "NOT_FOUND", message: "Record not found" },
{ status: 404 }
);
}
}
// Unknown errors — log and return generic message
console.error("Unhandled API error:", error);
return NextResponse.json(
{ code: "INTERNAL_ERROR", message: "An unexpected error occurred" },
{ status: 500 }
);
}The key decisions here. First, the ApiError class uses static factory methods. Writing throw ApiError.notFound("Product") is clearer than throw new ApiError(404, "NOT_FOUND", "Product not found"). Second, the handleApiError function handles multiple error types — application errors, validation errors, database errors — in a single function. Third, unknown errors never leak internal details to the client. The actual error goes to the server log. The client gets a generic message.
In EuroParts Lanka, this single handleApiError function handles errors across all 47 endpoints. When I added Prisma error code P2003 (foreign key constraint violation) six months after launch, every endpoint got the improvement with one line change.
Rate Limiting
If your API is public-facing and has no rate limiting, you are one bad actor away from a very expensive Vercel bill. I use an in-memory sliding window for development and Redis-backed rate limiting in production.
// lib/api/rate-limit.ts
import { NextRequest, NextResponse } from "next/server";
interface RateLimitConfig {
windowMs: number;
maxRequests: number;
}
const rateLimitStore = new Map<string, { count: number; resetAt: number }>();
export function rateLimit(config: RateLimitConfig) {
return (request: NextRequest): NextResponse | null => {
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
const key = `${ip}:${request.nextUrl.pathname}`;
const now = Date.now();
const entry = rateLimitStore.get(key);
if (!entry || now > entry.resetAt) {
rateLimitStore.set(key, { count: 1, resetAt: now + config.windowMs });
return null;
}
if (entry.count >= config.maxRequests) {
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
return NextResponse.json(
{ code: "RATE_LIMITED", message: "Too many requests" },
{
status: 429,
headers: {
"Retry-After": String(retryAfter),
"X-RateLimit-Limit": String(config.maxRequests),
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": String(entry.resetAt),
},
}
);
}
entry.count++;
return null;
};
}For production with Redis (what I use on FreshMart):
// lib/api/rate-limit-redis.ts
import { Redis } from "@upstash/redis";
const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!,
});
export async function rateLimitRedis(
identifier: string,
maxRequests: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
const key = `rate_limit:${identifier}`;
const now = Math.floor(Date.now() / 1000);
const windowStart = now - windowSeconds;
const pipeline = redis.pipeline();
pipeline.zremrangebyscore(key, 0, windowStart);
pipeline.zadd(key, { score: now, member: `${now}:${Math.random()}` });
pipeline.zcard(key);
pipeline.expire(key, windowSeconds);
const results = await pipeline.exec();
const currentCount = results[2] as number;
return {
allowed: currentCount <= maxRequests,
remaining: Math.max(0, maxRequests - currentCount),
resetAt: now + windowSeconds,
};
}This uses a sorted set sliding window. It is more accurate than a fixed window counter because it does not have the burst problem at window boundaries. On FreshMart, the public product search endpoint gets 100 requests per minute per IP. The order submission endpoint gets 10 per minute. Different limits for different risk levels.
The Upstash Redis integration works perfectly on Vercel Edge Runtime since it is HTTP-based. No TCP connections, no cold start penalties.
Pagination — Cursor-Based
Offset-based pagination breaks at scale. When you run SELECT * FROM products OFFSET 10000 LIMIT 20, the database still scans those first 10,000 rows. On EuroParts Lanka with 180,000+ parts, offset pagination past page 50 was noticeably slow. Cursor-based pagination solves this completely.
// app/api/products/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const paginationSchema = z.object({
cursor: z.string().uuid().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
direction: z.enum(["forward", "backward"]).default("forward"),
});
export async function GET(request: NextRequest) {
const params = Object.fromEntries(request.nextUrl.searchParams);
const validated = paginationSchema.safeParse(params);
if (!validated.success) {
return NextResponse.json(
{ code: "VALIDATION_ERROR", message: "Invalid pagination parameters" },
{ status: 400 }
);
}
const { cursor, limit, direction } = validated.data;
const products = await db.product.findMany({
take: limit + 1, // Fetch one extra to determine hasMore
cursor: cursor ? { id: cursor } : undefined,
skip: cursor ? 1 : 0, // Skip the cursor item itself
orderBy: { createdAt: "desc" },
});
const hasMore = products.length > limit;
const items = hasMore ? products.slice(0, limit) : products;
return NextResponse.json({
items,
pagination: {
hasMore,
nextCursor: hasMore ? items[items.length - 1].id : null,
count: items.length,
},
});
}The trick is fetching limit + 1 records. If you get back more than limit items, there are more pages. The extra item tells you without requiring a separate count query. The response includes a nextCursor that the client passes back for the next page.
On the frontend, this pairs perfectly with React Query's useInfiniteQuery:
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["products"],
queryFn: ({ pageParam }) =>
fetch(`/api/products?cursor=${pageParam ?? ""}&limit=20`).then((r) =>
r.json()
),
getNextPageParam: (lastPage) =>
lastPage.pagination.hasMore ? lastPage.pagination.nextCursor : undefined,
});Infinite scroll, load-more buttons, virtual lists — cursor pagination handles all of them cleanly. The database performance stays constant regardless of how deep into the dataset you paginate.
File Upload Handling
File uploads in Route Handlers use the Web API FormData interface. No multer. No busboy. Just the platform.
// app/api/uploads/route.ts
import { NextRequest, NextResponse } from "next/server";
import { withAuth } from "@/lib/api/auth";
import { ApiError, handleApiError } from "@/lib/api/errors";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"];
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
export const POST = withAuth(async (request, _context, session) => {
try {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
throw ApiError.badRequest("No file provided");
}
if (!ALLOWED_TYPES.includes(file.type)) {
throw ApiError.badRequest(
`Invalid file type. Allowed: ${ALLOWED_TYPES.join(", ")}`
);
}
if (file.size > MAX_FILE_SIZE) {
throw ApiError.badRequest("File size exceeds 5MB limit");
}
const buffer = Buffer.from(await file.arrayBuffer());
const filename = `${session.user.id}/${Date.now()}-${file.name}`;
// Upload to your storage provider
const url = await uploadToStorage(buffer, filename, file.type);
return NextResponse.json({ url, filename, size: file.size }, { status: 201 });
} catch (error) {
return handleApiError(error);
}
});Three critical validations: file exists, file type is allowed, file size is within limits. On EuroParts Lanka, users upload part images. On FreshMart, product photos. Both follow this exact pattern — the only things that change are the allowed types and size limits.
The file.arrayBuffer() method reads the entire file into memory. For large files, you would stream to storage instead. But for images under 10MB, this is perfectly fine and simpler to reason about.
A note on Vercel: the default body size limit for serverless functions is 4.5MB. If you need larger uploads, either use Edge Runtime (which supports streaming bodies) or upload directly to S3/Cloudflare R2 using presigned URLs and skip your API entirely.
Webhook Endpoints
Webhooks are API routes that other services call. Stripe sends payment confirmations. GitHub sends push events. Twilio sends SMS delivery receipts. The pattern is always the same: verify the signature, parse the payload, process idempotently.
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json(
{ code: "UNAUTHORIZED", message: "Missing signature" },
{ status: 401 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch {
return NextResponse.json(
{ code: "UNAUTHORIZED", message: "Invalid signature" },
{ status: 401 }
);
}
// Idempotency check — prevent duplicate processing
const existing = await db.webhookEvent.findUnique({
where: { externalId: event.id },
});
if (existing) {
return NextResponse.json({ received: true });
}
// Store the event before processing
await db.webhookEvent.create({
data: {
externalId: event.id,
type: event.type,
payload: event.data.object as Record<string, unknown>,
status: "pending",
},
});
// Process based on event type
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutComplete(event.data.object as Stripe.Checkout.Session);
break;
case "invoice.payment_failed":
await handlePaymentFailed(event.data.object as Stripe.Invoice);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
// Mark as processed
await db.webhookEvent.update({
where: { externalId: event.id },
data: { status: "processed", processedAt: new Date() },
});
return NextResponse.json({ received: true });
}Three non-negotiable patterns here. First, signature verification using request.text() — not request.json(). Stripe needs the raw body to verify the HMAC signature. If you parse it as JSON first, the verification fails. Second, idempotency. Stripe retries failed webhook deliveries. If your handler is not idempotent, you will process the same payment twice. The webhookEvent table prevents that. Third, store-then-process. If processing fails, you still have the event payload and can retry later.
On FreshMart, the Stripe webhook handles order fulfillment. A duplicate charge would mean shipping two orders for one payment. The idempotency check has prevented that exact scenario at least three times in the past year.
Testing API Routes
I test API routes with actual HTTP requests against the running server. Not unit tests with mocked Request objects. Real requests. Real responses.
// __tests__/api/products.test.ts
import { describe, it, expect, beforeAll } from "vitest";
const BASE_URL = "http://localhost:3000/api";
describe("POST /api/products", () => {
let authToken: string;
beforeAll(async () => {
// Get a real auth token from your test auth flow
const res = await fetch(`${BASE_URL}/auth/test-login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "test@example.com",
password: "test-password",
}),
});
const data = await res.json();
authToken = data.token;
});
it("returns 401 without authentication", async () => {
const res = await fetch(`${BASE_URL}/products`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Test Product" }),
});
expect(res.status).toBe(401);
const body = await res.json();
expect(body.code).toBe("UNAUTHORIZED");
});
it("returns 400 with invalid body", async () => {
const res = await fetch(`${BASE_URL}/products`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({ name: "" }),
});
expect(res.status).toBe(400);
const body = await res.json();
expect(body.code).toBe("VALIDATION_ERROR");
expect(body.details).toHaveProperty("name");
});
it("creates a product with valid data", async () => {
const res = await fetch(`${BASE_URL}/products`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({
name: "Brake Pad Set",
price: 4500,
sku: "BP-12345",
categoryId: "test-category-uuid",
}),
});
expect(res.status).toBe(201);
const product = await res.json();
expect(product.name).toBe("Brake Pad Set");
expect(product.id).toBeDefined();
});
});Each test covers one behavior. The test names describe what the API does, not what the test does. I test the error paths first because those are the paths most likely to break silently.
For webhook testing, I use Stripe CLI's local forwarding:
stripe listen --forward-to localhost:3000/api/webhooks/stripeThis gives you a local webhook secret and forwards real Stripe test events to your development server. You can trigger specific events with:
stripe trigger checkout.session.completedNo mocking. Real Stripe events hitting your actual handler. This is how I caught the raw body parsing issue on FreshMart before it hit production.
My API Checklist
Every API endpoint I ship goes through this checklist. Not sometimes. Every time. I keep this pinned in my project management tool and I do not merge a PR that adds an endpoint without every box checked.
Before writing code:
- [ ] Route follows RESTful naming:
/api/resourcesfor collections,/api/resources/[id]for single items - [ ] HTTP methods map correctly: GET reads, POST creates, PUT replaces, PATCH updates, DELETE removes
- [ ] Response shape documented: what fields, what types, what status codes
Request handling:
- [ ] Zod schema validates all input — body, query params, route params
- [ ] Error responses use the structured
{ code, message, details }format - [ ] Content-Type is checked before parsing JSON body
- [ ] File uploads validate type, size, and count
Authentication and authorization:
- [ ] Endpoint requires authentication unless explicitly public
- [ ] Role-based access control enforced at the handler level
- [ ] Resource ownership verified: users can only access their own data
- [ ] API keys have scoped permissions, not blanket access
Resilience:
- [ ] Rate limiting configured per endpoint based on risk level
- [ ] Database queries use parameterized inputs (Prisma handles this, but double-check raw queries)
- [ ] External API calls have timeouts and retry logic
- [ ] Webhook endpoints verify signatures and process idempotently
Performance:
- [ ] Pagination is cursor-based for list endpoints
- [ ] Database queries are indexed for the access patterns used
- [ ] N+1 queries eliminated: use
includeorselectin Prisma - [ ] Response payloads include only necessary fields
Observability:
- [ ] Errors logged with request context (method, path, user ID)
- [ ] Slow queries detected and logged (Prisma query events)
- [ ] Rate limit hits tracked for abuse detection
This checklist is not bureaucracy. It is the difference between an API that works in development and one that survives production traffic from real users at 3 AM when you are asleep.
Key Takeaways
Next.js API routes — Route Handlers in the App Router — are powerful enough for production APIs. I have proven this on multiple live applications serving real customers and processing real payments. But out of the box, they give you nothing. No validation, no auth, no error handling, no rate limiting. You build all of that yourself.
The patterns in this article are not theoretical. They come from EuroParts Lanka (180,000+ parts, complex search and filtering), FreshMart (e-commerce with Stripe payments and order management), and several other production applications. They have handled hundreds of thousands of requests without a single unstructured error reaching a client.
The core philosophy is simple: validate everything with Zod, handle errors consistently, authenticate before anything else, rate limit based on risk, paginate with cursors, verify webhook signatures, and test with real HTTP requests.
If you are building something with Next.js and need help architecting your API layer, check out my services. I have done this enough times to know where the edge cases hide.
*Uvin Vindula is a full-stack and Web3 engineer based between Sri Lanka and the UK. He builds production applications with Next.js, React, TypeScript, and Solidity. You can find more of his writing at iamuvin.com↗ or reach him at contact@uvin.lk.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.