From API Routes and Server Actions to oRPC in Next.js

On the ScreenshotOne dashboard migration from API routes and Server Actions to oRPC for clearer boundaries, better typing, and a more maintainable codebase.

Blog post 10 min read

Written by

Dmytro Krasun

Published on

oRPC offers everything that API routes, Server Actions (Server Functions), and tRPC provide, but goes further by adding more capabilities in a more coherent way—without introducing any magic and instead relying on well-established standards (like OpenAPI, for example).

However, there were several reasons why I hadn’t used it earlier.

Migration from Go and HTML/CSS to Next.js

The first version of the ScreenshotOne dashboard was built with pure Go, HTML, and CSS. No frameworks, except Bootstrap for UI. I was most experienced with this stack, and it allowed me to build and launch the product quickly.

But once the product started to grow, the dashboard required more and more features, and the stack quickly became a bottleneck. I wasn’t experienced enough with client-side development. However, I already saw that React could be a good fit for the dashboard UI. The mental model of reasoning about state and how it is reflected in the UI resonated with me. However, I didn’t know how to approach it, and refactoring seemed like a big investment at that moment.

Later, about two or three years ago, I discovered Next.js and started experimenting with it. It included all the tooling around React, and I realized that by relying on it, I could focus purely on shipping features. So, I went ahead and refactored the entire dashboard to use Next.js. This indeed helped me scale the dashboard to a complex UI and ship a ton of features.

The ScreenshotOne dashboard

However, on the server side, the journey was less smooth, and it took me some time to find an approach that would be a good fit for ScreenshotOne in the long term.

API routes

There were different versions of them, but I followed the suggested approach by the Next.js documentation and went with their API routes for the server-side logic.

Familiarity and convenience

A typical component code looked like:

import React, { useState } from "react";
function ChangeRoleButton({ memberId }) {
const [processing, setProcessing] = useState(false);
const [newRole, setNewRole] = useState("developer");
async function handleChangeRole() {
setProcessing(true);
const response = await fetch("/api/organization/change-role", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
memberId,
newRole,
}),
});
setProcessing(false);
// handle response...
}
return (
<div>
<select
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
disabled={processing}
>
<option value="admin">Admin</option>
<option value="developer">Developer</option>
</select>
<button onClick={handleChangeRole} disabled={processing}>
{processing ? "Changing..." : "Change Role"}
</button>
</div>
);
}

And in addition to that, I needed to implement the server-side logic in a separate file:

export const POST = withVerifiedUser(async (request) => {
await connection();
const user = request.user;
if (!user || !canChangeRoles(user.organizationRole)) {
return NextResponse.json({ message: "Unauthorized" }, { status: 403 });
}
const body = await request.json();
const parseResult = changeRoleSchema.safeParse(body);
if (!parseResult.success) {
return NextResponse.json({ userErrorMessage: "Invalid request data" }, { status: 400 });
}
const { memberId, newRole } = parseResult.data;
// change role logic...
return NextResponse.json(result);
});

The main advantage of API routes was that I didn’t need any external dependencies and could write all the code in a familiar way. However, that way had a few critical downsides.

Downsides

1. A lot of boilerplate glue code. Parsing input, validating payloads, handling errors, processing responses, and describing types is repeated in every route.

2. Weak end-to-end type safety. Unless you manually share schemas, it’s easy to lose track of all types. It takes a lot of discipline to avoid descending into chaos.

3. Manual contracts. The entire API surface (routes, options, response types) is implicit and scattered everywhere instead of being defined in one place.

4. Harder refactoring. API routes are string-based and loosely coupled, so renaming and refactoring them is easy to break.

There are many more reasons, but even just these made it clear I needed a better alternative to API routes.

Server Actions

Next.js introduced the concept of Server Actions (now called Server Functions), which allows you to write server-side code like this:

"use server";
import { z } from "zod";
const ChangeRoleSchema = z.object({
memberId: z.string(),
newRole: z.enum(["admin", "developer"]),
});
export async function changeRole(formData: FormData) {
const parsed = ChangeRoleSchema.safeParse({
memberId: formData.get("memberId"),
newRole: formData.get("newRole"),
});
if (!parsed.success) {
throw new Error("Invalid input");
}
const user = await requireAuthenticatedUser();
if (!canChangeRoles(user.organizationRole)) {
throw new Error("Unauthorized");
}
const { memberId, newRole } = parsed.data;
// change role logic...
return { success: true };
}

And use it directly in the client as:

"use client";
import { changeRole } from "@/actions/change-role";
export function ChangeRoleButton({ memberId }) {
async function handleSubmit(formData: FormData) {
formData.set("memberId", memberId);
await changeRole(formData);
}
return (
<form action={handleSubmit}>
<select name="newRole">
<option value="admin">Admin</option>
<option value="developer">Developer</option>
</select>
<button type="submit">Change role</button>
</form>
);
}

Safe Server Actions

When Server Actions became production-ready, I migrated all the API calls to them. I also used the next-safe-action library to reduce the boilerplate code even more.

"use server";
const action = actionClient
.use(authMiddleware)
.inputSchema(
z.object({
name: z.string().min(1),
})
)
.action(async ({ ctx, parsedInput }) => {
return { greeting: `Hello, ${parsedInput.name}!` };
});

Safe Actions solved a lot of issues:

  1. Type-safety and input validation.
  2. Middleware for authentication and authorization.
  3. Consistent error handling.

And many more.

They removed a lot of pitfalls of raw Server Actions and improved our Next.js codebase by far.

But that doesn’t change the underlying architecture. The application boundary remains implicit and tightly coupled to a framework feature. Because Safe Server Actions are still a thin wrapper around Server Actions.

Security concerns

Another aspect that made me uncomfortable with Server Actions was security issues.

At a conceptual level, Server Actions are not just local function calls. Under the hood they are public HTTP endpoints implemented through the React Server Components runtime and the React Flight protocol. Every action exposed in your codebase becomes an addressable server entrypoint.

Actions should always be treated like public APIs and must perform authentication, authorization, and validation on the server side. Relying on the client or assuming an action is “internal” can easily lead to vulnerabilities. And if the boundaries are not handled carefully, this creates a surprisingly large attack surface.

Remote Code Execution (RCE)

A vulnerability tracked demonstrated that maliciously crafted serialized payloads could manipulate the deserialization process used by the React Flight protocol. By exploiting JavaScript internals and prototype access during deserialization, attackers could potentially execute arbitrary code on the server. This type of vulnerability is particularly dangerous because it happens at the framework transport layer, not inside your application code.

Exposing source code

Another reported issue showed that specially crafted requests could sometimes bypass safeguards and expose compiled Server Action source code. If that happens, attackers may gain access to internal business logic, implementation details, or references to sensitive infrastructure. Even if secrets are not directly leaked, revealing internal code paths can significantly help attackers craft more targeted exploits.

DoS

One more issue demonstrated that malformed payloads could trigger expensive deserialization paths or infinite loops, causing high CPU usage and hanging server processes. Because Server Actions sit inside the application runtime rather than a clearly defined API gateway, these kinds of issues can be harder to isolate operationally.

Input validation, SSRF, and authorization vulnerabilities

Server Actions can appear deceptively similar to local function calls within React components, which makes it easy to overlook the fact that they are exposed as public endpoints. Without rigorous server-side input validation and explicit authorization checks, these actions may inadvertently allow attackers to perform unauthorized operations or introduce security risks such as SSRF or broken access control.

Always update the framework

Of course, you must always keep the Next.js updated, apply security patches quickly, and treat every Server Action as a fully exposed server endpoint with strict validation and authorization.

And of course, with enough discipline and following the best practices a lot of issues can be avoided, but that doesn’t make it a nice experience to use Server Actions.

Unnecessary errors in logs

And even if there were no issues anymore, there was a certain kind of errors (“Failed to find Server Action”) that appeared in the ScreenshotOne dashboard logs and frustrated me:

Logs

After applying all the possible fixes, the errors didn’t disappear. Since even if you fix everything, there might be automated vulnerabilirity testers checking periodically for vulnerabilities in your codebase. And it creates a lot of noise in the logs.

You can’t do it if you use them in the codebase, but one of the ways to fix that is to block Server Actions entirely. And that’s what I did eventually once migrated to oRPC.

oRPC

But the most profound reason to migrate to oRPC isn’t just the downsides of Server Actions—many of those are avoidable. It’s the upsides and new opportunities I saw by migrating to oRPC:

1. Explicit boundaries between client and server. With oRPC, every server-side operation is defined as a clear, isolated procedure—no more implicit or “magical” boundaries controlled by framework conventions.

2. Better control over request and error handling. oRPC encourages me to design the flow of requests, authentication, and error handling on my own. And keep all that consistent.

3. A structure that is easier to reuse outside of React if I ever need to. Procedures and routers in oRPC are not tightly coupled to any particular runtime or UI framework. If I ever need to scale the dashboard or extract the server and make it standalone, it is an easy move.

4. And less dependence on one specific Next.js mechanism and magic. When coding with AI you need less magic, not more.

5. Plus OpenAPI and public API support if I need to adjust the dashboard for agents to use. Built-in support for OpenAPI in oRPC means the API can be automatically documented and exposed publicly if needed. This is invaluable if I want to allow third-party integrations, create self-service developer experiences, or make the dashboard itself agent-friendly—all without duplicating logic or maintaining a separate API surface.

Once I refactored all the Server Actions to oRPC, it made me envy AI coding agents writing code. The ergonomics is so nice it makes me want to code manually again:

import "server-only";
import { z } from "zod";
import { os } from "@orpc/server";
const StorageConfigurationSchema = z.object({
credentials: z.object({
endpoint: z.string().url(),
accessKeyId: z.string(),
secretKey: z.string(),
defaultBucket: z.string(),
}),
});
export const storageRouter = {
testConfiguration: os.input(StorageConfigurationSchema).handler(async ({ input }) => {
await requireAuthenticatedUser();
try {
return await testStorageConnection(input.credentials);
} catch {
return {
valid: false,
errorMessage: "Failed to test storage configuration.",
};
}
}),
updateConfiguration: os.input(StorageConfigurationSchema).handler(async ({ input }) => {
const user = await requireAuthenticatedUser();
const updated = await saveStorageConfiguration(user.organizationId, input.credentials);
return { success: true, updated };
}),
};

oRPC versus tRPC

This was an important question because tRPC is the more established choice and it is also very good.

But why choose tRPC if oRPC has everything that tRPC provides, and more, right out of the box? OpenAPI support is built in, contract-first development is supported, and it works with standard schema libraries such as Zod. That combination mattered a lot to me.

Check out oRPC comparison for details on the differences between oRPC and tRPC.

Blocking Server Actions entirely

Once the migration was successful, tested and I felt I can keep going with the new solution, I went one step further: I blocked Server Actions in the codebase and added a check to prevent introducing them again—the build will fail if Server Actions are used.

In case if more developers work in the codebase in the future, I did not want the architecture to drift back into a mixed model where some mutations use oRPC and others quietly reintroduce Server Actions because they look convenient in the moment.

Also, it removed the issues with unnecessary errors in the logs.

Check out this guide on how to block Server Actions (now Server Functions) entirely if you ever need to.

At the time of writing this article, oRPC is developed by a single developer. Sponsoring the project, even for just one month, is the least ScreenshotOne can do to support it.

If you are using oRPC or find it to be an interesting project, consider sponsoring it as well. Every dollar counts, whether it’s $5, $50, or $500.

Check out other open-source projects that ScreenshotOne has sponsored.

Thoughts

Every approach has its upsides and downsides:

  1. API routes are the original Next.js way to create HTTP-based endpoints using file-based routing, letting you handle requests and responses directly with minimal setup.
  2. Server Functions (previously Server Actions) let you write server-side logic and call it directly from the client, enabling data updates without separate API endpoints.
  3. tRPC provides typesafe, contract-based API calls where your input/output types are shared between server and client, removing the need for manual type definitions.

But oRPC offered me the most upsides of all approaches combined (along with additional capabilities) while leaving me with the least downsides.

If your product is growing, your logs are getting noisy, and your server boundary feels increasingly implicit, it may be worth asking whether your current approach is still worth the tradeoff, and maybe give a try to oRPC.

Read more Engineering

Interviews, tips, guides, industry best practices, and news.

View all posts
Let's build a screenshot API

Let's build a screenshot API

Two years have passed since the launch of ScreenshotOne, and I want to do a fun coding exercise and build a tiny subset of what the API is today, but from scratch.

Read more

Automate website screenshots

Exhaustive documentation, ready SDKs, no-code tools, and other automation to help you render website screenshots and outsource all the boring work related to that to us.