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.2.3"

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.12.1

💻

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.2.3"

FEEDBACK NEEDED!

❤️

Thank you!

Michał Michalczuk

michalczukm.xyz

Repo with code & presentation

Next.js is my main web server for all APIs. And I like it!

By Michał Michalczuk

Next.js is my main web server for all APIs. And I like it!

[EN] Why you won’t use anything more specialized like Fastify or Nest? Why you are using Next.js not in the way it was designed? You might ask. Because it is way good enough for my cases, and more flexible than I initially thought. In this presentation, I’ll give you arguments for betting on Next.js as a delivery layer for your REST & GraphQL APIs. With a sip of middleware, some libraries, and tweaking the next.config you can squeeze a lot from it. But .. is it performant? Well, we will see :)

  • 182