Where Next.js really runs your code

A Next.js app with the App Router spans two execution environments. Server components, Server Actions, and route handlers run in a confidential environment where you can safely hold secrets and enforce business logic. Client components execute in the browser, where all code and data are exposed to the user. Anything sensitive — API keys, authorization decisions, database queries — must never end up in that public environment.

Next.js gives you two markers to keep these worlds apart. Import the server-only package in any module that must run exclusively on the server; this makes the bundler fail if client code ever tries to pull it in. For components that intentionally run in the browser, add 'use client' at the top of the file to signal the bundler to enter the client boundary.

Centralizing data access with a DAL

As a codebase grows, the logic for fetching and mutating data tends to spread out, and so do the authentication and authorization checks guarding those operations. A Data Access Layer (DAL) pulls all data operations into one place, sitting between your application logic and your persistent storage.

Every read or write that crosses this layer should pass through the same sequence:

  1. Verify the user is authenticated, so you know who is making the request.
  2. Verify the authenticated user is authorized to perform the requested action on the target data.
  3. Only then fetch or mutate the data in storage.
Update data flow with additional checks in our authorization server. Update data flow with additional checks in our authorization server. Update data flow with additional checks in our authorization server. Update data flow with additional checks in our authorization server.

Beyond roles: relationship-based authorization

Role-based access control (RBAC) is simple: a role bundles permissions, and you assign roles to users. But complex applications often need access decisions at the level of an individual resource, not just a resource type. A document might be shared with one person, viewable by a team, and editable by its owner — no fixed role captures that.

Relationship-based access control (ReBAC) takes a different approach. Instead of looking up a user's permissions, you examine the relationship between a user (or group) and a resource (or group of resources). These relationships often derive from context rather than explicit assignments. If a user is a member of a group that has access to a folder, they automatically have access to every file in that folder — no need to define each file relationship explicitly.

OpenFGA is an open-source fine-grained authorization solution that implements this model. Its managed counterpart, Okta FGA, is built on the same engine, DSL, and SDKs, so code transfers between them directly.

Setting up the authorization model

OpenFGA requires an authorization model written in its domain-specific language (DSL). A model describing a user, file, and folder — roughly the shape of a cloud storage app — declares the resource types and the relations each can have:

model

schema 1.1

type user

type file

relations

define can_delete: owner or owner from parent

define can_share: owner or owner from parent

define can_view: viewer or owner or viewer from parent

define can_write: owner or owner from parent

define is_owned: owner

define is_shared: can_view but not owner

define owner: [user]

define parent: [folder]

define viewer: [user, user:*]

type folder

relations

define can_create_file: owner or owner from parent

define can_create_folder: owner or owner from parent

define can_share: owner or owner from parent

define can_view: viewer or owner or viewer from parent

define owner: [user]

define parent: [folder]

define viewer: [user, user:*] or owner or viewer from parent

The decision engine also needs data to evaluate. That data lives in tuples, each holding three values:

  • A user: a resource consumer, such as an application user or even a folder
  • An object: the resource being protected, like a file
  • A relation: the relationship between the two, such as owner or parent

With the model and tuples in place, add the SDK to your project:

npm install @openfga/sdk

The SDK exposes a client with methods for all OpenFGA operations, including the check method that answers whether a given action is permitted for a user on an object:

import { OpenFgaClient } from '@openfga/sdk';

const fgaClient = new OpenFgaClient({

// A link to your OpenFGA instance

apiUrl: process.env.FGA_API_URL,

// The ID of your store, the collection of tuples, on OpenFGA

storeId: process.env.FGA_STORE_ID,

// The ID of your Authorization model. This changes with each change to the

// model, and can be overwritten with each check

authorizationModelId: process.env.FGA_MODEL_ID,

});

Enforcing checks inside the DAL

Every method in your DAL becomes a natural place to enforce authorization. A getFile action first verifies the user is authenticated, then asks OpenFGA whether that user can view the requested file, and only after both checks pass fetches and returns the file:

export async function getFile(fileId) {

try {

// Check if the user is authenticated

if (await !isAuthenticated()) {

return { error: "Unauthorized" };

}

// The user is authenticated so we can grab their ID

const userId = await getUserId();

// Check with OpenFGA if the user can view the file we're trying to fetch

const { allowed } = await openfgaClient.check({

user: `user:${userId}`,

relation: "can_view",

object: `file:${fileId}`,

});

// If the user is not authorized, we'll show an error

if (!allowed) {

return { error: "Forbidden" };

}

// The user was authorized, so we'll fetch our file and return it

return await getFileFromStore(fileId);

} catch (error) {

return { error };

}

}

A writeFile action follows the same pattern, with one addition. Writing new data means OpenFGA needs to know about it. You must create two new tuples when a file is uploaded: one marking the current user as the file's owner, and one setting the folder as the file's parent:

export async function uploadFile(parent, file) {

try {

// Check if the user is authenticated

if (await !isAuthenticated()) {

return { error: "Unauthorized" };

}

// The user is authenticated, so we can grab their ID

const userId = await getUserId();

// Check with OpenFGA if the user can create new files in the current location

const { allowed } = await openfgaClient.check({

user: `user:${userId}`,

relation: "can_create_file",

object: `folder:${parent}`,

});

// If the user is not authorized, we'll show an error

if (!allowed) {

return { error: "Forbidden" };

}

// Write the file to a persistent location

const {fileId} = await writeFile(file);

// Write OpenFGA tuples for the new file

await openfgaClient.writeTuples([

{

user: `user:${userId}`,

relation: "owner",

object: `file:${fileId}`,

},

{

user: `folder:${parent}`,

relation: "parent",

object: `file:${fileId}`,

},

]);

return file;

} catch (error) {

return { error };

}

}

Routing all reads and writes through a dedicated authorization service consolidates your authorization logic in one place. Auditing is simpler than tracing if…else branches across the codebase, and every application that asks OpenFGA yes/no questions gets the same consistent answers — even when the underlying rules change, the applications themselves don't need updates.

Fail closed, not open

Optimistic, success-by-default error handling works in many parts of an application, but not with user data. The safe assumption is that a user is neither authenticated nor authorized until proven otherwise. In the code samples, both the authentication check and the authorization check run first, and each returns an error immediately on failure. Data is only touched when both checks have explicitly passed.

Key takeaways

A DAL centralizes data operations and gives authentication and authorization a single, consistent enforcement point in your Next.js application. Adding fine-grained authorization with OpenFGA on top of that layer lets you validate every individual action against your authorization model, so unauthorized requests are blocked at the boundary before they ever reach your data.