AND I LIKE IT!
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
Already have the API
Add support for new consumer
MVP
🤔
🤞
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 } });
}
// ~/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',
},
});
}
/**
*
* @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
}
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,
});
};
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 | ✅ |
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();
}
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 | ✅ |
🥲
🥲
🥲
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).*)",
],
};
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"
🍊 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"
Michał Michalczuk
Repo with code & presentation