is my main web server for all APIs

AND I LIKE IT!

Michał Michalczuk

Senior Fullstack Engineer @ Tektit consulting

 

michalczukm.xyz

🧑‍💻

Caching

Metrics

Auth

Middleware

OpenAPI specs

Schema validation
(zod, yup, joi etc)

Proper routing with HTTP methods

Background jobs

Streaming
(files, images)

Programable setup and teardown

...

Michał Michalczuk

michalczukm.xyz

 

 

  • Already have the API

  • Add support for new consumer

  • MVP

Why use Next.js?

🤔

🤞

Next.js App Router + Route Handlers

export async function GET(
  request: NextRequest
): Promise<NextResponse<readonly Post[]>> {
  const get = getSearchParams<Post>(request);

  const filter: Partial<Post> = {
    title: get('title'),
    author: get('author'),
    tags: get('tags')?.split(','),
  };

  return NextResponse.json(await getPosts(filter));
}

export async function POST(request: NextRequest): Promise<NextResponse<Post>> {
  const post = await request.json();
  const newPost = await createPost(post);
  const location = `${request.nextUrl.href}/${newPost.id}`;

  return NextResponse.json(newPost, { status: 201, headers: { location } });
}

✍️ Handling different HTTP methods

Same for all runtimes

But...

// ~/api/posts/[id]/image
export async function GET(
  _: NextRequest,
  { params }: { params: { id: string } }
): Promise<NextResponse> {
  const postId = +params.id;

  const postImage = await fs.open(
    path.join(process.cwd(), `src/data/images/posts/${postId}.png`),
    'r'
  );
  const size = (await postImage.stat()).size;

  const stream = postImage.createReadStream({
    autoClose: true,
  });

  // This is interesting - old `pages` NextApiResponse type handled Node.js streams
  return new NextResponse(stream as unknown as ReadableStream, {
    headers: {
      'Content-Type': 'image/png',
      'Content-Length': size.toString(),
      'Content-Disposition': 'inline',
    },
  });
}

✍️ Streaming (files, images)

✍️ OpenAPI specifications

/**
 * 
 * @swagger
 * /api/posts:
 *   get:
 *     summary: 'Retrieve posts'
 *     description: 'This endpoint retrieves posts. You can filter the posts by title, author, and tags.'
 *     parameters:
 *       - name: 'title'
 *         in: 'query'
 *         description: 'Title of the post'
 *         required: false
 *         type: 'string'
 *       ....
 *     responses:
 *       '200':
 *         description: 'Successful operation'
 *         schema:
 *           type: 'array'
 *           items:
 *             $ref: '#/definitions/Post'
 * definitions:
 *   Post: ...
 */
export async function GET(
  request: NextRequest
): Promise<NextResponse<readonly Post[]>> {
import { z } from 'zod';

const putPostSchema = z.object({
  id: z.number(),
  title: z.string(),
  content: z.string(),
  author: z.string(),
  tags: z.array(z.string()),
});

// ~/api/posts/[id]
export async function PUT(
  request: NextRequest,
  { params }: { params: { id: string } }
): Promise<NextResponse<Post | { error: object | string }>> {
  if(headers().get('content-type') !== 'application/json') {
    return NextResponse.json({ error: 'Invalid content type' }, { status: 400 });
  }

  const parseResult = await putPostSchema.safeParseAsync(await request.json());

  if (!parseResult.success) {
    const error = parseResult.error;
    return NextResponse.json({ error }, { status: 400 });
  }
  
  const post = parseResult.data;
  // save the post, return response
}

✍️ Schema validation, zod, yup etc.

import { NextApiRequest, NextApiResponse } from 'next';
import { withValidation } from 'next-validations';
import { z } from 'zod';

const schema = z.object({
  username: z.string().min(6),
});

const validate = withValidation({
  schema,
  type: 'Zod',
  mode: 'body',
});

const handler = (req: NextApiRequest, res: NextApiResponse) => {
  res.status(200).json(req.body);
};

export default validate(handler);
// Path: ~/middleware.ts
import { NextResponse, NextRequest, NextMiddleware } from 'next/server';

export const config = {
  matcher: [
    '/api/((?!health|greet|token|$).*)',
  ],
};

//...

export const middleware: NextMiddleware = async (req: NextRequest) => {
  const headers = new Headers(req.headers);
  headers.set('x-michalczukm', new Date().toUTCString());

  const authorized = await isAuthorized(req);

  if (!authorized) {
    return NextResponse.json({ message: "Unauthorized" }, {
      status: 401,
      headers: headers,
    });
  }

  return NextResponse.next({
    headers: headers,
  });
};

✍️ Middleware

import jwt from 'jsonwebtoken';

const isAuthorized = (req: NextRequest) => {
  const token = req.headers.get('authorization')?.replace('Bearer ', '') ?? '';

  // Won't work 😢
  // jsonwebtoken uses `node:crypto`
  return new Promise<boolean>((resolve) => {
    jwt.verify(
      token,
      configuration.authSecret,
      { algorithms: ['HS512'] },
      (err, decoded) => {
        resolve(!!decoded && !err);
      }
    );
  });
};
export const GET = 
	(request: NextRequest) => requireAuth((request, response) => {
		  //...
}
/** @type {import('next').NextConfig} */
const nextConfig = {
  headers: async () => [
    {
      source: '/api/:path*',
      headers: [
        {
          key: 'X-Powered-By',
          value: 'It is API, but not Express',
        },
        {
          // Don't do this at home,
          // But as you see - this one is calculated on build time
          key: 'X-Build-Date',
          value: new Date().toISOString(),
        },
      ],
    },
  ],
};

export default nextConfig;
Feature Will it fit?
Proper routing with HTTP methods ✅ mind the methods set limitation
Schema validation
OpenAPI specs
Middleware
🫳 works, but is pretty limited
Streaming (files, images)
Caching ✅ use f.e. Redis
Metrics
Auth

Next.js Pages Router + API Routes

export default async function handler(
  request: NextApiRequest,
  response: NextApiResponse
) {
  if (request.method === 'GET') {
    const get = getSearchParams<Post>(request);
    const filter: Partial<Post> = {
      title: get('title'),
      author: get('author'),
      tags: get('tags')?.split(','),
    };

    return response.json(await getPosts(filter));
  }
  if (request.method === 'POST') {
    const newPost = await createPost(await request.body);
    const location = `${request.url}/${newPost.id}`;

    response.setHeader('location', location);
    return response.status(201).json(newPost);
  }

  return response.status(405).end();
}

✍️ Handling different HTTP methods

Feature Will it fit?
Proper routing with HTTP methods ✅ mind the methods set limitation
Schema validation
OpenAPI specs
Middleware
🫳 works, but is pretty limited
Streaming (files, images)
Caching ✅ use f.e. Redis
Metrics
Auth

🥲

🥲

🥲

next-connect

import { createEdgeRouter } from 'next-connect';

const router = createEdgeRouter<NextRequest, {}>();
router
  .get(async (request) => {
    const get = getSearchParams<Post>(request);

    const filter: Partial<Post> = {
      title: get('title'),
      author: get('author'),
      tags: get('tags')?.split(','),
    };

    return NextResponse.json(await getPosts(filter));
  })
  .post(async (request) => {
    const post = await request.json();
    const newPost = await createPost(post);
    const location = `${request.nextUrl.href}/${newPost.id}`;

    return NextResponse.json(newPost, { status: 201, headers: { location } });
  });


export async function GET(request: NextRequest): Promise<Response> {
  return (await router.run(request, {})) as Response;
}
export async function POST(request: NextRequest): Promise<Response> {
  return (await router.run(request, {})) as Response;
}
const assertContentType =
  (contentType: 'application/json' | 'application/xml') =>
  async (request: NextRequest, _context: RequestContext, next: NextHandler) => {
    if (request.headers.get('content-type') !== contentType) {
      return NextResponse.json(
        { error: 'Invalid content type' },
        { status: 400 }
      );
    }

    return next();
  };

const router = createEdgeRouter<NextRequest, {}>();
router
  .use(assertContentType('application/json'))
  .post(async (request) => {
    const post = await request.json();
    const newPost = await createPost(post);
    const location = `${request.nextUrl.href}/${newPost.id}`;

    return NextResponse.json(newPost, { status: 201, headers: { location } });
  });
// middleware.ts
const router = createEdgeRouter<NextRequest, NextFetchEvent>();

router.get("/about", (request) => {
  return NextResponse.redirect(new URL("/about-2", request.url));
});

router.use("/dashboard", (request) => {
  if (!isAuthenticated(request)) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next();
});

router.all(() => {
  // default if none of the above matches
  return NextResponse.next();
});

export function middleware(request: NextRequest, event: NextFetchEvent) {
  return router.run(request, event);
}

export const config = {
  matcher: [
    "/((?!api|_next/static|_next/image|favicon.ico).*)",
  ],
};

what we did once

import { match, P } from 'ts-pattern';

export default async function handler(
  request: NextApiRequest,
  response: NextApiResponse
) {
  const { index: _, ...queryParams } = request.query;

  return match(request)
    .with(
      {
        method: 'GET',
        query: {
          index: ['posts'],
          title: P.optional(P.string),
          author: P.optional(P.string),
          tags: P.optional(P.string),
        },
      },
      async () => {
        const get = getSearchParams<Post>(request);

        const filter: Partial<Post> = {
          title: get('title'),
          author: get('author'),
          tags: get('tags')?.split(','),
        };

        return response.json(await getPosts(filter));
      }
    )
    .with(
      {
        method: 'POST',
        query: {
          index: ['posts'],
        },
      },
      async () => {
        const post = request.body;
        const newPost = await createPost(post);
        const location = `${request.url}/${newPost.id}`;

        response.setHeader('location', location);
        return response.status(201).json(newPost);
      }
    )
   .otherwise(() => response.status(404).json({ error: 'Not found' }));
}
{
  /// ...
  .with(
    {
      method: 'PUT',
      query: {
        index: ['posts', P.select('id', P.string)],
      },
      headers: {
        'content-type': 'application/json',
      },
    },
    async ({ id }) => {
      const result = await putPostSchema.safeParseAsync(request.body);
      if (!result.success) {
        return response.status(400).json({ error: result.error });
      }

      const updated = await updatePost(+id, result.data);
      if (!updated) {
        return response.status(404).json({ error: 'Post not found' });
      }

      const location = `${request.url}/${updated.id}`;
      response.setHeader('location', location);
      return response.status(200).json(updated);
    }
  )
  // ...
}
// 404 if no query matches
.otherwise(() => response.status(404).json({ error: 'Not found' }));}

🕐 🕑 🕒 🕓 🕔 🕕 🕖 🕗 🕘 🕙 🕚 🕛

Caching

Metrics

Auth

Middleware

OpenAPI specs

Schema validation
(zod, yup, joi etc)

Proper routing with HTTP methods

Background jobs

Streaming
(files, images)

Programable setup and teardown

...

😫

import { createServer } from "http";
import { parse } from "url";
import next from "next";

const port = parseInt(process.env.PORT || "3000", 10);
const dev = process.env.NODE_ENV !== "production";
const app = next({ dev });
const handle = app.getRequestHandler();

app.prepare().then(() => {
  createServer((req, res) => {
    const parsedUrl = parse(req.url!, true);
    handle(req, res, parsedUrl);
  }).listen(port);

  console.log(
    `> Server listening at http://localhost:${port} as ${
      dev ? "development" : process.env.NODE_ENV
    }`,
  );
});

// do any extra initial work
// add some error handling
// handle process events etc.

😅

😅

😠

"next": "14.1.0"

The performance

⚡️

🍊 Nextjs with App Router + Route Handlers

 

🍊🔌 Nextjs with App Router + Route Handlers
+ next-connect app

 

 

💾 Nextjs with Pages Router + API routes
 

 

💾 🧾 Nextjs with Pages Router + API routes
+ request pattern matching

Github repo:

📏 Fastify app

Github repo:

// GET ~/api/posts

export async function GET(
  request: NextRequest
): Promise<NextResponse<readonly Post[]>> {
  const get = getSearchParams<Post>(request);

  const filter: Partial<Post> = {
    title: get('title'),
    author: get('author'),
    tags: get('tags')?.split(','),
  };

  return NextResponse.json(await getPosts(filter));
}
const isAuthorized = async (req: NextRequest) => {
  const token = req.headers.get('authorization')?.replace('Bearer ', '') ?? '';
  const secret = new TextEncoder().encode(configuration.authSecret);

  try {
    await jose.jwtVerify(token, secret, {
      issuer: 'org:michalczukm:issuer',
      audience: 'org:michalczukm:audience',
    })

    return true
  } catch {
    return false
  }
};

export const middleware: NextMiddleware = async (req: NextRequest) => {
  const headers = new Headers(req.headers);
  headers.set('x-michalczukm', new Date().toUTCString());

  const authorized = await isAuthorized(req);

  if (!authorized) {
    return NextResponse.json({ message: "Unauthorized" }, {
      status: 401,
      headers: headers,
    });
  }

  return NextResponse.next({
    headers: headers,
  });
};
export const authRoutes: FastifyPluginAsync = async (
  fastify: FastifyInstance
) => {
  fastify.addHook('preHandler', xHeaderMiddleware);
  fastify.addHook('preHandler', authMiddleware);

  fastify.get<{ Querystring: Partial<Post>; Reply: readonly Post[] }>(
    '/posts',
    async (request) => {
      const filter = request.query;
      return getPosts(filter);
    }
  );
  
  //...
);

-c 100

     100 concurrent connections

 

-d 40

      40 seconds duration

 

-p 10

      10 pipelining factor

- CPU: Apple M1

- RAM: 16 GB

- macOS: Ventura 13.4

- node.js: v20.10.0

💻

npx autocannon 
  -c 100 -d 40 -p 10 
  <SERVICE_URL>/api/posts 
  -H Authorization="Bearer eyJhbGciOiJIUzUxMiJ9.eyJpc3MiOiJvcmc6bWljaGFsY3p1a206aXNzdWVyIiwiYXVkIjoi
					b3JnOm1pY2hhbGN6dWttOmF1ZGllbmNlIn0.CHaB_qZjIuKMYc487Jscj-KCyj1OuW8-e1R1d1n0Kuc
					sF2t_XtXNDzjOk7qowhpVLwF_GBzgUvPYoCi3BUmSyQ"

🥁

🥁

🥁

💔

  • You already have the API

  • Adding support for another client - like Mobile

  • MVP

👇

API Routes > Route Handlers

"next": "14.1.0"

Thank you!

Michał Michalczuk

michalczukm.xyz

Repo with code & presentation