Wedging Next into Wedge

, published:


Recently, I had some time to explore open source projects and I came across Wedge – a web application for managing a restaurant. Having pulled the project code, I spun it up locally and was quite drawn by its design and the overall look and feel. The project was written in TypeScript, used Next.js 15 as its core framework and shadcn for UI.

Having dived into the code a bit, it turned out that the presentation layer was at a quite advanced state but the internals were in their infancy. Data management was based on mocks and shared across pages/components via React context – useful for POC’ing but nowhere near the goal. That was good because it meant there was a truckload of interesting tasks to take up ! And they had to be done within the context of Next.js 15 + Shadcn, a pretty popular combo these days.

The first thing that was missing and that would quickly become a dependency for other tasks was to establish client, server, database data sharing flow. At this point, I’ve worked on a few React commercial projects but Next.js was new to me. The first dive into the world of Next was a bit overwhelming – it quickly became apparent just how many pathways you can take with Next.js at your side. From statically generated sites, through server side rendered websites that don’t even need much JavaScript to work, all the way to a classic SPA. You can do all of these. Obviously, the restaurant management context narrowed down the list of options but only just a bit.

Approaches

A restaurant management app is a very dynamic environment – making reservations, placing orders that need to pop up for other staff members immediately, updating order statuses to let the waiters know when the food is ready, etc. At a first glance it beacons a client heavy app with a ton of JavaScript, client initiated requests for data updates and perhaps a client-side store to share those updates across components and maintain a consistent state across the app. Next.js lets us do that but it also offers other possibilities for creating interactive applications. Let’s explore these approaches in the following sections.

SPA with Next

Next.js lets us create SPA-like apps. That can be achieved by composing the app with client components and adding API handlers to manage http requests. The app state could be persisted and shared across components via a client store or with React Query or SWR with correctly set-up caching.

We end up with a familiar development model when coming from traditional SPAs. There’s no need to ‘juggle’ server and client side components – interactivity, hooks, context, web APIs can be used across the whole application. We get a simpler, more ‘traditional’ mental model and probably a shallower learning curve, which are not trivial factors when considering maintainability. In addition, a restaurant management app is behind a login screen so SEO is not a concern, which further adds to the SPA appeal for this scenario.

On the flip side, we’re losing out on lots of Next.js features and it feels like we’re parting ways with the core Next way of building web applications. One of the biggest benefits we might be losing is app state management and data correctness when syncing across clients – this is a valid concern in a restaurant management app where we’ll have multiple staff members, each operating different devices. What Next.js offers is a paradigm shift compared to traditional client side state management and syncing. Consider the following diagram:

Two browser tabs start in state A. In client tab 1, an event or action mutates state A into state B. The change is then propagated so client tab 2 also updates, replacing its state A with the same state B.

In a traditional SPA approach, each client maintains its own copy of the server state and needs to set up mechanisms to keep that state up to date. Next.js simplifies that by moving application state to the server side. This reduces the client-side complexity and virtually sidesteps the issue of state drift across different clients. Here is the visual representation of the difference:

Three browser tabs each maintain their own state: client tab 1 has state A, client tab 2 has state B, and client tab 3 has state C. Each tab is responsible for keeping its own state up to date independently.
Three browser tabs each hold only a state representation, while the actual state lives centrally on the server. All clients reflect and synchronize with the single source of truth managed by the server.

With SPA’s, we have multiple states to look after. In Next.js server-centered approach, we get one state that gets projected onto different clients. 

Another drawback of creating a SPA with Next.js is the necessity to create and maintain API routes (server request handlers).. This results in a larger code base and more complexity compared to Next.js server centered app management.

Hybrid Approach – Next actions && SWR / ReactQuery

As an alternative to a SPA-like application architecture one could go for a hybrid approach combining server side data fetching and Next.js actions for data mutations in server components and ReactQuery or SWR for data management in client components. There are non-trivial advantages to this approach:

– easy, nicely encapsulated data fetching in client components with built-in support for data polling,

– good control over client component state in relation to fetched data: error, isPending, data states supplied by the mentioned client data management libraries,

– robust data caching and revalidation in client components that allows ‘projecting’ server state onto client, helps avoid having a client store and thus helps eliminate client state drift phenomenon,

– easy integration / data revalidation of websocket events, example:

const { isPending, isError, data, error } = useQuery({
    queryKey: [‘orders’, ‘active’],
    queryFn: fetchActiveOrders,
    refetchInterval: 5000, // Update every 5 seconds as backup to Websocket updates
  });

  // WebSocket for instant updates
  useEffect(() => {
    socket.onmessage = (event) => {
      const update = JSON.parse(event.data);
      if (update.type === ‘ORDER_COMPLETED’) {
        queryClient.invalidateQueries([‘orders’]);        // Refetch orders on orders-related event
      }
    };
  }, []);

– better, easier control over the UI layer while still sticking to ‘server-side-state’ principle

While offering quite a tempting set of functionalities, this approach has some disadvantages. 

We end up with two ways of data fetching: on the server side and on the client side. This might cause dilemas/debates whether ‘X’ should be a client or a server component. 

Just like with the SPA approach, it is necessary to create API endpoints to handle client side requests. Using any of these libraries essentially adds an extra layer of data management code and produces a more complex application infrastructure. On top of that we’re introducing a big external library to learn and manage which adds to the maintenance overhead.

Next.js – Server Centered Approach

Another approach worth exploring aligns with the default Next.js mechanisms for data management. As Next.js grew, it started encompassing ever more scenarios that in the past had to be covered with external tools. However, at version 15, the framework appears to supply a lot out of the box. In the server-centered approach we would end up relying on:

– server side fetching in server components,

– Next.js actions for data mutations both in server and client components,

– streaming data mechanism in client components

For live updates across different clients, we’d still need an external eventing mechanism, such as server side events or websockets.  

Let’s take a look at the currently available set of data management tools.

Server side fetching:

In  Next, server components can directly access databases and external APIs during rendering, providing fresh data on every request without client-side fetching complexity. Let’s take a look at how we could fetch a list of orders for the kitchen page:

// queries/order.ts
export const fetchPendingOrders = () => {  // get orders from db  return db.orders.findMany({
      where: { status: ‘pending’ },
      include: { items: true, table: true },
      orderBy: { createdAt: ‘asc’ }
  })
// app/kitchen/page.ts
import { fetchPendingOrders } from ‘@/queries/order’;
export default async function KitchenPage() {
  // Direct database access in Server Component
  const pendingOrders = await fetchPendingOrders();
 
  return (
    <div>
      <h1>Kitchen Orders</h1>
      {pendingOrders.map(order => (
        <OrderCard key={order.id} order={order} />
      ))}
    </div>
  )
}

In server components, we can directly invoke queries to a database. We can also await the response and deal with output (data, errors) using the standard Promise based API. Both database queries and accessing external APIs are handled this way.
Dealing with any data fetching directly in the component with a simple Promise API and the ability to await the Promise resolution sound great, but server components have pretty severe limitations. For example, they can’t use event handlers, don’t have access to web APIs and cannot use React hooks. That’s why Next.js apps are a wicker basket of interwoven server and client side components. Let’s now take a look at how we can feed data to the client components.

Client side fetching:

For client side data queries, we initiate fetching in a server component and pass promises to client components. The client component will be wrapped with a suspense element, which handles the loading state until the provided promises resolve. Here is a simple example:

// app/orders/page.js (Server Component)

export default function OrdersPage() {
// Initiate fetch in Server Component 
const ordersPromise = fetchPendingOrders(); // returns a promise
  return (
    <div>
      <h1>Live Orders</h1>
      <Suspense fallback={<div>Loading orders…</div>}>
        <LiveOrdersList ordersPromise={ordersPromise} />
      </Suspense>
    </div>
  )
}

This mechanism allows us to build non-blocking, streamable component trees and can be applied both to server and client components.

Mutating data with server actions:

Server Actions are essentially asynchronous functions executed on the server side. They provide a seamless way to handle data mutations directly from the components. They sugar coat the internals of http client-server communication, automatically handle serialization and package it up as a JavaScript function that can be called from both server and client side components.

Server actions mean that the whole layer of the browser-side http clients and their server side handlers can be side-stepped and is encapsulated in the form of a simple function. Any kind of data mutation can be handled this way.

On top of that, Next 15 uses React 19 which introduced useActionState hook for projecting request state onto UI. This resembles the way of interacting with request state exposed by libraries such as ReactQuery.

‘use client’

import { useActionState } from ‘react’
import { createOrder } from ‘@/actions/orders’

const initialState = { error: null; success: null; somethingElseToCommunicate: ‘Hey, click me to see some action !’}
export default function CreateOrder({ tableNumber, orderItems }) {
  const [state, createOrderAction, pending] = useActionState(createOrder, initialState);
 
  const handleCreateOrder = () => {
    const orderData = {
      tableNumber,
      items: orderItems,
      customerId: ‘customer-123’
    }
   
    // server action call
    createOrderAction(orderData)
  }
 
  return (
    <div>      {state.somethingElseToCommunicate} // ‘Hey, click me…’
      <button
        onClick={handleCreateOrder}
        disabled={pending}
      >
        {pending ? ‘Creating Order…’ : ‘Place Order’}
      </button>
     
      {state?.success && (
        <p className=”text-green-600 mt-2″>
          Order #{state.orderId} created successfully!
        </p>
      )}
      {state?.error && (
        <p className=”text-red-600 mt-2″>
          Error: {state.error}
        </p>
      )}
    </div>
  )
}

I want to highlight that useActionState is a rather important tool in the new React 19 / Next 15 toolbox as it lets us cleanly encapsulate the stages and states of user initiated actions, be it a form submission or any other event. As shown in the CreateOrder snippet, we can project pending and resolved states onto the component quite easily. On top of that, the state variable can be shaped to our liking and can be used to design a consistent return interface for our actions, for example, to clearly indicate whether the action was successful or not.
This concludes the examination of the available query and mutation tools. It appears that Next 15 offers robust data management mechanisms even for a rather dynamic restaurant management application.

Advantages of server-centered approach:

– simplified data management, syntactic sugar coats all http

communication implementations both on server and client side – much less server-client data passing code to write,

– data fetching from DB or external APIs happens in server components 

and therefore on the server side. When getting data from a DB, we’re potentially fetching very close to the data source,

– encourages keeping app state on the server side which can simplify

client side state management (no client data store maintenance and state syncing between tabs, browser windows, devices). Single source of truth for all clients/consumers. This can be an advantage for applications where multiple clients modify the same state,

– no need to define Next.js API routes to handle client http requests,

– integrates well with the  ‘wicker basket’ of Next.js server-client components
– In latest Next, useActionState hook provides the same basic functionality as SWR/ReactQuery for projecting request state onto UI

Some disadvantages:

Probably, the biggest concern here is the initially steep learning curve when coming from the traditional React SPA world. The world of interwoven server and client components is a different mental model than doing everything on the client side. On top of that  there is some syntax and patterns that React developers need to get used to. 

We also don’t get built-in data-polling as backup for websocket connection loss, which could be a nice touch in a dynamic, multiclient app.

Client side data streaming would typically be done with the Suspense component and while it might provide consistency it could also be less flexible compared to for example SPA with store approach where we have total control of where and how we project data flow states. 

Conclusions

While each approach offers an interesting set of advantages, my choice would be to go Next server-centered style for an already Next-based dynamic app.  At this stage, except for live events, Next framework offers enough in itself to cover most of our data management needs. In addition, by using mainly ‘native’ Next tools, we keep things simple which can have a big impact on maintainability. What would you choose for your next project ?