Published July 24th, 2025
Let's start with some context: I created a journal platform that's modern, has AI features and more. But I saw a lot of people worried about the privacy implications of having their private thoughts stored in a random SAAS app. So, I set out to create an open source version. This project is using NextJS since most people know it and it has the most amount of documentation.
But there's a huge problem: NextJS does not support the OpenAPI Spec for their API routes and doesn't have a good way to setup documentation either. Now, in most cases, it's better to use a separate backend using something like Hono, Fastify or even something like tRPC. These usually come with more tools, tend to be safer and more bug-resistant, come with more performance and typically support industry standards or at least type-safety.
But since this is open source and I wanted users to one-click deploy the entire journal platform on a single domain, I decided to do something crazy.
Hono is a fast, lightweight, web application framework built on web standards. It's got a lot of support, it's extremely performant, can run on the edge and comes with utilities to handle middleware, CORs, streaming, RPC and more.
https://hono.dev/
Hono actually already has a way for you to setup Hono and NextJS (here). The way it works is relatively simple:
1// app/api/[[...route]]/route.ts
2
3import { Hono } from 'hono'
4import { handle } from 'hono/vercel'
5
6export const runtime = 'edge'
7
8const app = new Hono().basePath('/api')
9
10app.get('/hello', (c) => {
11 return c.json({
12 message: 'Hello Next.js!',
13 })
14})
15
16export const GET = handle(app)
17export const POST = handle(app)
And that pretty much sets up Hono and a test api route at /api/hello
.
This is where things get a little more complicated. Firstly, let's look at the routing file from above but with the new OpenAPI stuff:
1// app/api/[[...route]]/route.ts
2
3import configureOpenAPI from "@backend/configure-open-api";
4import createApp from "@backend/create-app";
5import { authMiddleware } from "@backend/middlewares/auth";
6import { handle } from "hono/vercel";
7
8// routes
9import index from "@backend/routes/index.route";
10import asset from "@backend/routes/asset/asset.index";
11import profile from "@backend/routes/profile/profile.index";
12import document from "@backend/routes/document/document.index";
13import goal from "@backend/routes/goal/goal.index";
14import logs from "@backend/routes/logs/logs.index";
15import tag from "@backend/routes/tag/tag.index";
16
17export const runtime = "edge";
18
19const app = createApp();
20
21configureOpenAPI(app);
22
23// Apply an auth check for all main routes
24app.use("/*", authMiddleware);
25
26// add all the other routes
27const routes = [index, profile, asset, document, goal, logs, tag] as const;
28
29routes.forEach((route) => {
30 app.route("/", route);
31});
32
33export type AppType = (typeof routes)[number];
34
35export const GET = handle(app);
36export const POST = handle(app);
37export const PUT = handle(app);
38export const DELETE = handle(app);
39export const PATCH = handle(app);
First thing you'll notice is that a lot more is going on, this is an example from the open source app I'm building and there's about 6 different things that all need CRUD endpoints.
You'll notice that we're calling createApp()
to set up the initial Hono instance. Let's look at the code for that. Also note that I created a /backend
folder separate to the /src
folder. The backend has a lot of files that have nothing to do with the frontend.
1// backend/create-app.ts
2
3import { OpenAPIHono } from "@hono/zod-openapi";
4import { requestId } from "hono/request-id";
5import { notFound, onError, serveEmojiFavicon } from "stoker/middlewares";
6import { defaultHook } from "stoker/openapi";
7
8import type { AppBindings } from "./types";
9
10export function createRouter() {
11 return new OpenAPIHono<AppBindings>({
12 strict: false,
13 defaultHook,
14 });
15}
16
17export default function createApp() {
18 const app = createRouter();
19
20 app.use(requestId()).use(serveEmojiFavicon("📝"));
21
22 app.notFound(notFound);
23 app.onError(onError);
24
25 return app;
26}
Now you can see the main package that is letting setup the OpenAPI spec: @hono/zod-openapi
. You'll also see that we're using something called stroker
— initially, this was from hono-open-api-starter which is a proper Hono project. Stroker simply gives us some reusable utilities.
Here we are setting up default messages for the not found
and on error
responses. We also have a little favicon for the API routes.
Now, we'll take a look at configureOpenAPI()
which add 2 new endpoints: api/doc
and api/reference
.
1import { Scalar } from "@scalar/hono-api-reference";
2import type { AppOpenAPI } from "./types";
3
4const defaultUrl = process.env.VERCEL_URL
5 ? `https://${process.env.VERCEL_URL}`
6 : "http://localhost:3000";
7
8export default function configureOpenAPI(app: AppOpenAPI) {
9 app.doc("api/doc", {
10 openapi: "3.0.0",
11 info: {
12 version: "1.0.0",
13 title: "Jadebook OSS API",
14 },
15 });
16
17 app.get(
18 "api/reference",
19 Scalar({
20 url: "http://localhost:3000/api/doc",
21 theme: "fastify",
22 layout: "modern",
23 defaultHttpClient: {
24 targetKey: "js",
25 clientKey: "fetch",
26 },
27 hideModels: false,
28 metaData: {
29 title: "Jadebook OSS API Reference",
30 description:
31 "Jadebook OSS API Reference — Provides a comprehensive API reference for the Jadebook OSS platform.",
32 },
33 servers: [
34 {
35 url: `${defaultUrl}`,
36 description: "Local server",
37 },
38 ],
39 persistAuth: true,
40 }),
41 );
42}
43
Here we're serving the OpenAPI Spec using version 3.0.0
and then adding the documentation that using the OpenAPI Spec to generate everything.
We're using something called Scalar which is an open source API platform and they're letting us create an interactive docs page for our API. They also have built-in support for some themes and layouts, here I'm using fastify
as the color theme and modern
as the layout.
Here's what it looks like:
Notice how every route starts withapi/
, this is a requirement since Hono only handles NextJS past/api
, if you don't add this, the endpoints will never be hit because Hono will not load.
Let's add the first route: Index
. This route is usually the first thing people see and it simply point to api/doc
.
1// backend/routes/index.route.ts
2
3import { createRoute } from "@hono/zod-openapi";
4import * as HttpStatusCodes from "stoker/http-status-codes";
5import { jsonContent } from "stoker/openapi/helpers";
6import { createMessageObjectSchema } from "stoker/openapi/schemas";
7import { createRouter } from "../create-app";
8
9const router = createRouter().openapi(
10 createRoute({
11 tags: ["Index"],
12 summary: "Jadebook OpenAPI Spec",
13 description:
14 "Get the OpenAPI spec for the Jadebook API. Note, all routes require an authorization header, for which the value can only be accessed on the client.",
15 method: "get",
16 path: "/doc",
17 responses: {
18 [HttpStatusCodes.OK]: jsonContent(
19 createMessageObjectSchema("Jadebook - OpenAPI Spec"),
20 "Jadebook - OpenAPI Spec",
21 ),
22 },
23 }),
24 (c) => {
25 return c.json(
26 {
27 message: "Jadebook - OpenAPI Spec",
28 },
29 HttpStatusCodes.OK,
30 );
31 },
32);
33
34export default router;
This is fairly basic, it simply creates a GET
route and it'll be shown under a Index
folder based on the tags.
Now let's increase the complexity. Let's assume that you have a Profile
resource and you need an endpoint for 2 things: GET (fetch) and PUT (update).
We create a folder like backend/routes/profile/
in which we'll have 4 files:
The Index
file will simply export everything in one place making it easier to use:
1// backend/routes/profile/profile.index.ts
2
3import { createRouter } from "@backend/create-app";
4import * as handlers from "./profile.handlers";
5import * as routes from "./profile.routes";
6
7const router = createRouter();
8
9// Define routes
10router.openapi(routes.getUserProfile, handlers.getUserProfile);
11router.openapi(routes.updateUserProfile, handlers.updateUserProfile);
12
13export default router;
The Routes
file will setup the path, the typesafe input and output, and some metadata:
1// backend/routes/profile/profile.routes.ts
2
3import { createRoute, z } from "@hono/zod-openapi";
4import * as HttpStatusCodes from "stoker/http-status-codes";
5import * as HttpStatusPhrases from "stoker/http-status-phrases";
6import { jsonContent } from "stoker/openapi/helpers";
7import { createMessageObjectSchema } from "stoker/openapi/schemas";
8import { selectProfileResponse, updateProfileBody } from "./profile.validation";
9
10const tags = ["Profile"];
11
12const internalServerErrorSchema = createMessageObjectSchema(
13 HttpStatusPhrases.INTERNAL_SERVER_ERROR,
14);
15
16export const getUserProfile = createRoute({
17 path: "/api/profile",
18 summary: "Get profile",
19 description: "Gets the user's profile",
20 method: "get",
21 tags,
22 responses: {
23 [HttpStatusCodes.OK]: jsonContent(
24 selectProfileResponse,
25 "The requested profile",
26 ),
27 [HttpStatusCodes.UNAUTHORIZED]: jsonContent(
28 createMessageObjectSchema("Unauthorized"),
29 "Authentication required",
30 ),
31 [HttpStatusCodes.TOO_MANY_REQUESTS]: jsonContent(
32 createMessageObjectSchema("Too many requests"),
33 "Rate limit exceeded",
34 ),
35 [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent(
36 internalServerErrorSchema,
37 "Internal server error",
38 ),
39 },
40});
41
42export const updateUserProfile = createRoute({
43 path: "/api/profile",
44 summary: "Update profile",
45 description: "Updates the user's profile",
46 method: "put",
47 request: {
48 body: jsonContent(updateProfileBody, "Profile update data"),
49 },
50 tags,
51 responses: {
52 [HttpStatusCodes.OK]: jsonContent(
53 createMessageObjectSchema("OK"),
54 "Profile updated",
55 ),
56 [HttpStatusCodes.BAD_REQUEST]: jsonContent(
57 createMessageObjectSchema("Bad request"),
58 "Invalid request data",
59 ),
60 [HttpStatusCodes.UNAUTHORIZED]: jsonContent(
61 createMessageObjectSchema("Unauthorized"),
62 "Authentication required",
63 ),
64 [HttpStatusCodes.TOO_MANY_REQUESTS]: jsonContent(
65 createMessageObjectSchema("Too many requests"),
66 "Rate limit exceeded",
67 ),
68 [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent(
69 internalServerErrorSchema,
70 "Internal server error",
71 ),
72 },
73});
74
75export type GetUserProfileRoute = typeof getUserProfile;
76export type UpdateUserProfileRoute = typeof updateUserProfile;
77
All the profile routes will be under a Profile
folder in the sidebar, this is what the const tags = ["Profile"];
does.
Then we serve the endpoint at "/api/profile"
using the either the GET
method or PUT
method, each having their own handler and responses.
You'll notice that we have selectProfileResponse
and updateProfileBody
— this is because all the input (so query params and the body) as well as the the output (what the handler returns) are type-safe and validated through Zod. Zod also works at runtime, so this isn't just a developer experience thing, this actually adds full validation and error handling to your API.
The Validation
file holds the Zod definitions in which you define everything:
1// backend/routes/profile/profile.validation.ts
2
3import { z } from "@hono/zod-openapi";
4
5export const selectProfileResponse = z.object({
6 created_at: z.string(),
7 current_streak: z.number(),
8 id: z.string().openapi({
9 description:
10 "The user's unique ID — reference to the Supabase Auth user ID",
11 }),
12 last_entry_date: z.string().nullable(),
13 longest_streak: z.number(),
14 profile_image: z.string().nullable(),
15 theme: z.string().nullable(),
16 updated_at: z.string(),
17 username: z.string().nullable(),
18});
19
20export const updateProfileBody = z
21 .object({
22 profile_image: z.string().nullable().optional(),
23 theme: z.string().nullable().optional(),
24 username: z.string().nullable().optional(),
25 })
26 .openapi({
27 description:
28 "Profile update data - all fields are optional for partial updates",
29 });
30
I talked about this earlier, but the "@hono/zod-openapi"
package extends Zod to include a helper for defining OpenAPI metadata.
You can see that the id
on the 200 response now has a description — In this case, we want the user to know that the id is also a reference to the Supabase Auth Id which might be useful to know.
Lastly, the Handler
file holds the actual route logic:
1// backend/routes/profile/profile.handlers.ts
2
3import * as HttpStatusCodes from "stoker/http-status-codes";
4import * as HttpStatusPhrases from "stoker/http-status-phrases";
5import type { AppRouteHandler } from "../../types";
6import type {
7 GetUserProfileRoute,
8 UpdateUserProfileRoute,
9} from "./profile.routes";
10
11export const getUserProfile: AppRouteHandler<GetUserProfileRoute> = async (
12 c,
13) => {
14 const userId = c.get("userId");
15 const supabase = c.get("supabase");
16
17 const fetchProfile = async () => {
18 // Use upsert to handle both getting existing profile and creating new one
19 const { data, error } = await supabase
20 .from("user")
21 .upsert(
22 {
23 id: userId,
24 },
25 {
26 onConflict: "id",
27 },
28 )
29 .select("*")
30 .single();
31
32 if (error) {
33 throw new Error(error.message);
34 }
35
36 return data;
37 };
38
39 try {
40 const profile = await fetchProfile();
41
42 return c.json(profile, HttpStatusCodes.OK);
43 } catch (error) {
44 console.error(error);
45
46 return c.json(
47 {
48 message: HttpStatusPhrases.INTERNAL_SERVER_ERROR,
49 },
50 HttpStatusCodes.INTERNAL_SERVER_ERROR,
51 );
52 }
53};
54
55export const updateUserProfile: AppRouteHandler<
56 UpdateUserProfileRoute
57> = async (c) => {
58 const userId = c.get("userId");
59 const supabase = c.get("supabase");
60 const updateData = c.req.valid("json");
61
62 try {
63 // Update the profile
64 const { error } = await supabase
65 .from("user")
66 .update({
67 ...updateData,
68 updated_at: new Date().toISOString(),
69 })
70 .eq("id", userId);
71
72 if (error) {
73 return c.json(
74 {
75 message: "Profile update failed",
76 },
77 HttpStatusCodes.BAD_REQUEST,
78 );
79 }
80
81 return c.json(
82 {
83 message: "Profile updated",
84 },
85 HttpStatusCodes.OK,
86 );
87 } catch (error) {
88 console.error(error);
89
90 return c.json(
91 {
92 message: HttpStatusPhrases.INTERNAL_SERVER_ERROR,
93 },
94 HttpStatusCodes.INTERNAL_SERVER_ERROR,
95 );
96 }
97};
98
The GET
route also creates an user if it doesn't already exist. This isn't ideal but the open source project is for personal use and so this was done simply to ship a little faster.
We have some variables coming from c
which is essentially context from the middleware and some other Hono stuff.
Since we're adding the GetUserProfileRoute
type to the route, if for any reason const profile = await fetchProfile();
does not match our defined response from the validation files, it'll give us an error. In this case, the Supabase client, is also typesafe and synced with the actual DB so we know the return type of the DB fetch.
You may not be using Supabase but this might still be helpful. We have an auth middleware file:
1// backend/middlewares/auth.ts
2
3import { createMiddleware } from "hono/factory";
4import { HTTPException } from "hono/http-exception";
5import type { AppBindings } from "../types";
6import { createClient } from "@/lib/supabase/server";
7
8/**
9 * Middleware to authenticate requests using Supabase. We use the `Authorization` header to get the JWT token.
10 * and have the user log in with it.
11 */
12export const authMiddleware = createMiddleware<AppBindings>(async (c, next) => {
13 const authorization = c.req.header("Authorization");
14
15 if (!authorization) {
16 throw new HTTPException(401, { message: "Missing Authorization header" });
17 }
18
19 const jwt = authorization.replace("Bearer ", "");
20
21 if (!jwt) {
22 throw new HTTPException(401, {
23 message: "Invalid Authorization header format",
24 });
25 }
26
27 const supabase = await createClient();
28
29 try {
30 const {
31 data: { user },
32 error,
33 } = await supabase.auth.getUser(jwt);
34
35 if (error) {
36 throw new HTTPException(401, { message: error.message });
37 }
38
39 if (!user) {
40 throw new HTTPException(401, { message: "User not found" });
41 }
42
43 // Set user data in context for use in routes
44 c.set("user", user);
45 c.set("supabase", supabase);
46 c.set("userId", user.id);
47
48 console.log("user", user);
49
50 await next();
51 } catch (error) {
52 if (error instanceof HTTPException) {
53 throw error;
54 }
55
56 throw new HTTPException(500, { message: "Authentication service error" });
57 }
58});
This does the following: Every API route that includes this middleware, must have an Authorization
header that is the user's JWT or access token, this is then used to sign the user in to the Supabase client. Then we add the user, user id and the authenticated client to the Hono context allowing the API routes to carry out operations.
In this case, the createClient()
sets up our DB and also adds type-safety. Supabase has a documation page for this here if you wish to learn more.
While it seems a little complicated, the result is pretty great.
You may also be wondering about the performance cost, and to be honest, it's not much. In fact, if you're using NextJS API routes, there will always be a performance drawback due to NextJS loading more things like helpers, specific to the NextJS framework. However, if you host the Hono separately like the hono-open-api-starter does, you'll get about a 20-30% performance increase.
So, yeah, thanks for reading and hopefully you learned something.