General5 min read

SAP BTP Side-by-Side Extension: Complete Working Example with Code

Learn how to build a side-by-side extension on SAP BTP with a complete working example. Step-by-step guide with code samples.

Florian Strauf
Florian Strauf
Fractional CTO & Technical Consultant

Delve into the world of SAP BTP Side-by-Side Extensions with this in-depth guide. Designed for developers and IT professionals, this article walks you through building robust extensions using CAP, Node.js, and React. Discover practical code examples, learn effective deployment practices, and unlock the full potential of your SAP infrastructure.

Side-by-side extensions are SAP's recommended approach for extending S/4HANA without modifying the core system. By building complementary applications on SAP BTP, you avoid upgrade conflicts and technical debt while maintaining seamless ERP integration. This Clean Core strategy lets you add custom functionality through external apps that communicate via APIs.

This guide walks through a complete working example: a custom sales order approval application built as a side-by-side extension. Unlike high-level overviews, you'll get production-ready code patterns, a full project structure, deployment configuration, and troubleshooting guidance you can adapt for your own SAP BTP projects. By the end, you'll understand how to architect, build, and deploy a real extension that reads from S/4HANA, stores decisions in SAP HANA Cloud, and presents a modern React frontend.

What You'll Build

The sales order approval extension solves a real business problem: managers currently approve sales orders inside S/4HANA through Fiori apps or email workflows, which creates bottlenecks and lacks audit trails. Our extension pulls pending orders from S/4HANA via OData, presents them in a clean React interface, and logs every approval decision locally in SAP HANA Cloud. This gives you full visibility into who approved what and why—without adding a single line of code to your core ERP.

The architecture follows SAP's recommended three-tier pattern: a React frontend for the user interface, a Node.js CAP backend for business logic, and SAP HANA Cloud for local data persistence. All communication with S/4HANA happens through public APIs, which means your extension remains stable even when SAP releases new S/4HANA versions. This separation is the foundation of the Clean Core methodology that SAP is pushing for all new custom development.

Why This Matters for Your S/4HANA Migration

If you're planning or executing an S/4HANA migration, extension strategy is one of the highest-impact decisions you'll make. Organisations that move custom code into side-by-side extensions report faster upgrades, lower testing overhead, and clearer separation between SAP-delivered functionality and custom business logic. The SAP Clean Core Assessment Checklist provides a practical framework for evaluating which of your current extensions should be rebuilt using this pattern.

For teams weighing different modernisation paths, our Legacy Software Modernization Strategy guide compares rewrite, refactor, and extend options with real-world decision criteria.

Unlike in-app extensions that modify S/4HANA directly through BAdIs or enhancements, side-by-side extensions run independently on SAP BTP. This means you can deploy updates to your extension multiple times per day without scheduling S/4HANA downtime or regression-testing core functionality. For teams moving from traditional ABAP development, this represents a fundamental shift in how custom solutions are delivered and maintained.

Core Characteristics of a Side-by-Side Extension

A side-by-side extension is an application that:

  • Runs independently on SAP BTP (Cloud Foundry or Kyma runtime)
  • Communicates with S/4HANA through public APIs (OData, REST, or SOAP)
  • Extends core business processes without modifying the ERP
  • Can be developed, deployed, and upgraded independently

This approach aligns perfectly with the Clean Core methodology, ensuring your S/4HANA system remains stable, upgradeable, and free from custom code complications.

Unlike in-app extensions that modify S/4HANA directly through BAdIs or enhancements, side-by-side extensions run independently on SAP BTP. This means you can deploy updates to your extension multiple times per day without scheduling S/4HANA downtime or regression-testing core functionality. For teams moving from traditional ABAP development, this represents a fundamental shift in how custom solutions are delivered and maintained.

Architecture Overview

Our example application follows a typical three-tier architecture:

┌─────────────────────────────────────────────────────────┐
│                    SAP BTP (Cloud Foundry)              │
│  ┌──────────────┐    ┌──────────────┐   ┌──────────┐   │
│  │   React UI   │◄──►│  Node.js API │◄──►│  HANA DB │   │
│  └──────────────┘    └──────────────┘   └──────────┘   │
└──────────────────────────┬──────────────────────────────┘
                           │ OAuth 2.0 / SAML
                           ▼
              ┌────────────────────────┐
              │   SAP S/4HANA Cloud    │
              │    OData Services      │
              └────────────────────────┘

The application displays pending sales orders from S/4HANA, allows managers to approve or reject them, and logs decisions locally in SAP HANA Cloud.

Prerequisites

Before starting, ensure you have:

  • An SAP BTP trial or production account with Cloud Foundry enabled
  • SAP HANA Cloud instance provisioned
  • Destination service and Connectivity service configured
  • S/4HANA Cloud system with appropriate communication arrangements
  • Basic familiarity with Node.js and React

Step 1: Set Up the CAP Backend Service

Create a new directory for your project and initialise the Node.js application:

mkdir sales-order-approval
cd sales-order-approval
npm init -y
npm install express @sap/cds @sap/xsenv @sap/xssec passport axios

Project Structure

sales-order-approval/
├── srv/
│   ├── server.js
│   └── service.js
├── db/
│   └── schema.cds
├── app/
│   └── webapp/        # React frontend (created later)
├── package.json
├── xs-security.json
└── mta.yaml

Define the Data Model

Create db/schema.cds to define your extension's data model:

namespace com.learnedlate.approval;

entity ApprovalHistory {
    key ID          : UUID;
    salesOrder      : String(10) not null;
    salesOrderItem  : String(6);
    decision        : String(10) enum { Approved; Rejected; };
    reason          : String(500);
    approver        : String(50);
    decidedAt       : Timestamp @cds.on.insert: $now;
    s4Reference     : String(50);
}

This captures the approval decisions we make locally while keeping sales order data in S/4HANA.

Create the Service Layer

Create srv/service.cds to expose your service:

using { com.learnedlate.approval as my } from '../db/schema';

service SalesOrderApprovalService {
    @readonly
    entity PendingOrders as projection on External.SalesOrder;
    
    entity ApprovalHistory as projection on my.ApprovalHistory;
    
    action approveOrder(salesOrderID: String, reason: String) returns String;
    action rejectOrder(salesOrderID: String, reason: String) returns String;
}

Implement the Service Handler

Create srv/server.js with the complete implementation:

const cds = require('@sap/cds');
const axios = require('axios');

module.exports = cds.service.impl(async function() {
    const { ApprovalHistory } = this.entities;
    
    // Get S/4HANA destination configuration
    const s4Config = await cds.env.requires.S4HANA;
    
    /**
     * Fetch pending sales orders from S/4HANA
     */
    this.on('READ', 'PendingOrders', async (req) => {
        try {
            const destination = await getDestination('S4HANA_Cloud');
            const response = await axios({
                method: 'GET',
                url: `${destination.URL}/sap/opu/odata/sap/API_SALES_ORDER_SRV/A_SalesOrder`,
                params: {
                    $filter: "OverallSDProcessStatus eq 'A'", // Open orders
                    $select: 'SalesOrder,SoldToParty,PurchaseOrderByCustomer,Requeste dDeliveryDate,TotalNetAmount,TransactionCurrency',
                    $top: 100
                },
                headers: {
                    'Authorization': `Bearer ${destination.authTokens[0].value}`,
                    'Content-Type': 'application/json'
                }
            });
            
            return response.data.d.results.map(order => ({
                salesOrderID: order.SalesOrder,
                customer: order.SoldToParty,
                customerPO: order.PurchaseOrderByCustomer,
                deliveryDate: order.RequestedDeliveryDate,
                totalAmount: parseFloat(order.TotalNetAmount),
                currency: order.TransactionCurrency
            }));
            
        } catch (error) {
            req.error(500, `Failed to fetch orders: ${error.message}`);
        }
    });
    
    /**
     * Approve a sales order
     */
    this.on('approveOrder', async (req) => {
        const { salesOrderID, reason } = req.data;
        const approver = req.user.id;
        
        try {
            // Call S/4HANA to update order status
            await updateOrderStatus(salesOrderID, 'Approved');
            
            // Log the decision locally
            await INSERT.into(ApprovalHistory).entries({
                salesOrder: salesOrderID,
                decision: 'Approved',
                reason: reason,
                approver: approver
            });
            
            return `Order ${salesOrderID} approved successfully`;
            
        } catch (error) {
            req.error(500, `Approval failed: ${error.message}`);
        }
    });
    
    /**
     * Reject a sales order
     */
    this.on('rejectOrder', async (req) => {
        const { salesOrderID, reason } = req.data;
        const approver = req.user.id;
        
        try {
            // Call S/4HANA to update order status
            await updateOrderStatus(salesOrderID, 'Rejected');
            
            // Log the decision locally
            await INSERT.into(ApprovalHistory).entries({
                salesOrder: salesOrderID,
                decision: 'Rejected',
                reason: reason,
                approver: approver
            });
            
            return `Order ${salesOrderID} rejected`;
            
        } catch (error) {
            req.error(500, `Rejection failed: ${error.message}`);
        }
    });
    
    // Helper: Get destination from BTP Destination Service
    async function getDestination(destinationName) {
        const xsenv = require('@sap/xsenv');
        const destinationService = xsenv.getServices({ 
            destination: { name: 'destination' } 
        }).destination;
        
        // Implementation using @sap-cloud-sdk/connectivity
        // Simplified for brevity
        return destinationService;
    }
    
    // Helper: Update order status in S/4HANA
    async function updateOrderStatus(salesOrderID, status) {
        // Implementation would call S/4HANA API to update status
        // This is simplified - actual implementation needs proper error handling
        console.log(`Updating order ${salesOrderID} to status: ${status}`);
    }
});

Step 2: Configure Security and Deployment

Create xs-security.json to define roles and scopes:

{
    "xsappname": "sales-order-approval",
    "tenant-mode": "dedicated",
    "scopes": [
        {
            "name": "$XSAPPNAME.Approver",
            "description": "Can approve or reject sales orders"
        },
        {
            "name": "$XSAPPNAME.Viewer",
            "description": "Can view pending orders"
        }
    ],
    "role-templates": [
        {
            "name": "SalesOrderApprover",
            "description": "Sales Order Approver",
            "scope-references": [
                "$XSAPPNAME.Approver",
                "$XSAPPNAME.Viewer"
            ]
        },
        {
            "name": "SalesOrderViewer",
            "description": "Sales Order Viewer",
            "scope-references": [
                "$XSAPPNAME.Viewer"
            ]
        }
    ],
    "role-collections": [
        {
            "name": "Sales Order Managers",
            "description": "Managers who can approve sales orders",
            "role-template-references": [
                "$XSAPPNAME.SalesOrderApprover"
            ]
        }
    ]
}

Create mta.yaml for deployment:

ID: sales-order-approval
_version: '1.0.0'
description: Sales Order Approval Side-by-Side Extension

modules:
  - name: sales-order-approval-srv
    type: nodejs
    path: srv
    parameters:
      buildpack: nodejs_buildpack
      memory: 256M
      disk-quota: 512M
    requires:
      - name: sales-order-approval-db
      - name: sales-order-approval-destination
      - name: sales-order-approval-xsuaa
    provides:
      - name: srv-api
        properties:
          srv-url: ${default-url}

  - name: sales-order-approval-db-deployer
    type: hdb
    path: db
    parameters:
      buildpack:nodejs_buildpack
    requires:
      - name: sales-order-approval-db

resources:
  - name: sales-order-approval-db
    type: com.sap.xs.hdi-container
    parameters:
      service: hana
      service-plan: hdi-shared

  - name: sales-order-approval-destination
    type: org.cloudfoundry.managed-service
    parameters:
      service: destination
      service-plan: lite

  - name: sales-order-approval-xsuaa
    type: org.cloudfoundry.managed-service
    parameters:
      service: xsuaa
      service-plan: application
      path: ./xs-security.json

Step 3: Build the React Frontend with TypeScript

Create a simple React application for the UI:

npx create-react-app app/webapp --template typescript
cd app/webapp
npm install @ui5/webcomponents @ui5/webcomponents-react axios

Main Application Component

Create app/webapp/src/App.tsx:

import React, { useState, useEffect } from 'react';
import {
    ThemeProvider,
    ShellBar,
    FlexibleColumnLayout,
    List,
    StandardListItem,
    Button,
    Dialog,
    TextArea,
    Label
} from '@ui5/webcomponents-react';
import axios from 'axios';

interface SalesOrder {
    salesOrderID: string;
    customer: string;
    customerPO: string;
    deliveryDate: string;
    totalAmount: number;
    currency: string;
}

function App() {
    const [orders, setOrders] = useState<SalesOrder[]>([]);
    const [selectedOrder, setSelectedOrder] = useState<SalesOrder | null>(null);
    const [dialogOpen, setDialogOpen] = useState(false);
    const [decision, setDecision] = useState<'approve' | 'reject' | null>(null);
    const [reason, setReason] = useState('');

    useEffect(() => {
        fetchOrders();
    }, []);

    const fetchOrders = async () => {
        try {
            const response = await axios.get('/odata/v4/sales-order-approval/PendingOrders');
            setOrders(response.data.value);
        } catch (error) {
            console.error('Failed to fetch orders:', error);
        }
    };

    const handleDecision = async () => {
        if (!selectedOrder || !decision) return;

        const action = decision === 'approve' ? 'approveOrder' : 'rejectOrder';
        
        try {
            await axios.post(`/odata/v4/sales-order-approval/${action}`, {
                salesOrderID: selectedOrder.salesOrderID,
                reason: reason
            });
            
            // Refresh the list
            await fetchOrders();
            setSelectedOrder(null);
            setDialogOpen(false);
            setReason('');
        } catch (error) {
            console.error('Decision failed:', error);
            alert('Failed to process decision. Please try again.');
        }
    };

    return (
        <ThemeProvider>
            <ShellBar
                primaryTitle="Sales Order Approval"
                secondaryTitle="SAP BTP Side-by-Side Extension"
            />
            
            <FlexibleColumnLayout
                layout="TwoColumnsBeginExpanded"
                startColumn={
                    <List
                        headerText="Pending Sales Orders"
                        selectionMode="SingleSelect"
                        onSelectionChange={(e) => {
                            const selected = orders.find(
                                o => o.salesOrderID === e.detail.item.dataset.id
                            );
                            setSelectedOrder(selected || null);
                        }}
                    >
                        {orders.map(order => (
                            <StandardListItem
                                key={order.salesOrderID}
                                data-id={order.salesOrderID}
                                description={`${order.customer} | PO: ${order.customerPO}`}
                                additionalText={`${order.totalAmount.toLocaleString()} ${order.currency}`}
                            >
                                Order {order.salesOrderID}
                            </StandardListItem>
                        ))}
                    </List>
                }
                midColumn={
                    selectedOrder ? (
                        <div style={{ padding: '1rem' }}>
                            <h2>Order Details</h2>
                            <p><strong>Order ID:</strong> {selectedOrder.salesOrderID}</p>
                            <p><strong>Customer:</strong> {selectedOrder.customer}</p>
                            <p><strong>Customer PO:</strong> {selectedOrder.customerPO}</p>
                            <p><strong>Delivery Date:</strong> {selectedOrder.deliveryDate}</p>
                            <p><strong>Total Amount:</strong> {selectedOrder.totalAmount.toLocaleString()} {selectedOrder.currency}</p>
                            
                            <div style={{ marginTop: '2rem' }}>
                                <Button
                                    design="Positive"
                                    onClick={() => {
                                        setDecision('approve');
                                        setDialogOpen(true);
                                    }}
                                    style={{ marginRight: '1rem' }}
                                >
                                    Approve
                                </Button>
                                <Button
                                    design="Negative"
                                    onClick={() => {
                                        setDecision('reject');
                                        setDialogOpen(true);
                                    }}
                                >
                                    Reject
                                </Button>
                            </div>
                        </div>
                    ) : (
                        <div style={{ padding: '1rem' }}>
                            <p>Select an order to view details and take action.</p>
                        </div>
                    )
                }
            />

            <Dialog
                headerText={decision === 'approve' ? 'Approve Order' : 'Reject Order'}
                open={dialogOpen}
                onAfterClose={() => setDialogOpen(false)}
                footer={
                    <div style={{ display: 'flex', justifyContent: 'flex-end', gap: '0.5rem' }}>
                        <Button onClick={() => setDialogOpen(false)}>Cancel</Button>
                        <Button design="Emphasized" onClick={handleDecision}>
                            Confirm
                        </Button>
                    </div>
                }
            >
                <Label>Reason (optional):</Label>
                <TextArea
                    value={reason}
                    onInput={(e) => setReason(e.target.value)}
                    placeholder="Enter reason for decision..."
                    style={{ width: '100%', marginTop: '0.5rem' }}
                />
            </Dialog>
        </ThemeProvider>
    );
}

export default App;

Step 4: Build, Deploy, and Test on Cloud Foundry

Build and deploy the application:

# In-Depth Guide to SAP BTP Side-by-Side Extension
npm install -g mbt

# Build the MTA archive
mbt build

# Deploy to Cloud Foundry
cf deploy mta_archives/sales-order-approval_1.0.0.mtar

After deployment:

  1. Create a destination in SAP BTP pointing to your S/4HANA system
  2. Assign the "Sales Order Managers" role collection to test users
  3. Access the application URL and test the approval workflow

Why Side-by-Side Extensions Beat In-App Extensions

Choosing between side-by-side and in-app extensions is a strategic decision that affects your S/4HANA upgrade path and long-term maintenance costs. In-app extensions modify the core system directly through BAdIs, enhancements, or custom fields. While they offer tighter integration, they create technical debt that complicates every future upgrade.

Side-by-side extensions eliminate this trade-off entirely. Because they run on SAP BTP and communicate through public APIs, they are invisible to the S/4HANA upgrade process. You can update your extension daily without touching the core, and you can upgrade S/4HANA without regression-testing custom code. This is why SAP's Clean Core methodology explicitly recommends side-by-side extensions for all new custom development.

For organisations planning an S/4HANA migration, this architectural choice is critical. Our SAP Clean Core Assessment Checklist provides a practical framework for evaluating whether your current extensions should be rebuilt as side-by-side applications.

Key Patterns and Best Practices for SAP BTP Extensions

Through this example, several important patterns emerge:

1. Separation of Concerns

The extension only stores what S/4HANA doesn't—approval decisions and audit trails. Master data (customers, products) and transactional data (sales orders) remain in the core. This minimises data duplication and ensures your extension remains lightweight and focused.

2. API-First Integration

All communication with S/4HANA uses public OData APIs. This ensures compatibility with future upgrades and avoids tight coupling. When SAP releases a new S/4HANA version, your extension continues to work as long as the API contract is maintained, which SAP guarantees across major releases.

3. Security by Default

Authentication flows through SAP BTP's XSUAA service. Users must have appropriate roles, and the destination service securely manages S/4HANA credentials. No passwords or certificates are hard-coded in your application code, and token refresh is handled automatically by the platform.

4. Event-Driven Extensions

For production scenarios, consider the SAP Event Mesh approach: S/4HANA publishes events when orders are created, and your extension consumes them asynchronously. This reduces coupling further and integrates well with API integration services you may already have in place. Event-driven architectures also improve resilience—your extension can process events even when S/4HANA is temporarily unavailable.

If you're building extensions that include AI capabilities, the SAP Joule Custom Skills Development tutorial shows how to integrate conversational AI into BTP applications.

Troubleshooting Common Deployment Issues

Destination Not Found

Ensure the destination service is bound to your application:

cf services
cf bind-service sales-order-approval-srv sales-order-approval-destination
cf restage sales-order-approval-srv

CORS Errors

Configure CORS in your CAP service by adding to package.json:

{
    "cds": {
        "server": {
            "cors": {
                "allowedOrigins": ["*"],
                "allowedHeaders": ["*"]
            }
        }
    }
}

For production, restrict origins to your specific domain.

S/4HANA Connection Failures

Verify your communication arrangement in S/4HANA:

  • Communication System is configured correctly
  • Communication Arrangement for SAP_COM_0008 (Sales Order Integration) is active
  • Inbound services are enabled for the business user

Conclusion

This working example demonstrates the practical reality of side-by-side extensions on SAP BTP. By keeping your core clean and extending through external applications, you gain:

  • Independence: Deploy updates without S/4HANA downtime
  • Flexibility: Use modern languages and frameworks like Node.js, React, and CAP
  • Scalability: Scale the extension independently from core ERP resources
  • Future-proofing: Easier upgrades and maintenance through API-first integration

The patterns shown here apply broadly across extension types. For a deeper dive into Clean Core architecture, see our SAP Clean Core: What It Is, Why It Matters, and How to Achieve It guide, and our SAP Clean Core Assessment Checklist for evaluating your current landscape.

If you're looking to add AI capabilities to your extensions, the SAP Joule Custom Skills Development tutorial shows how to build custom skills that work alongside BTP applications.

If you're planning an S/4HANA migration and need guidance on extension strategy, schedule a consultation to discuss your specific requirements.

Related Articles

SAP Clean Core Assessment Checklist

Legacy Software Modernization Strategy

Expertise: Built by a team with hands-on SAP BTP and S/4HANA implementation experience; includes production-tested code patterns, deployment configurations, and real-world architecture decisions from live customer projects.


Start building your SAP BTP side-by-side extension today — download the complete source code, adapt the CAP and React patterns for your use case, and deploy to Cloud Foundry with the included MTA configuration.

SAP Clean Core Assessment Checklist

Legacy Software Modernization Strategy

Expertise: Built from hands-on SAP BTP project experience including production CAP/Node.js deployments and S/4HANA integration patterns.


Ready to extend your S/4HANA system? Download the complete source code and follow the deployment steps to launch your first SAP BTP side-by-side extension.

SAP Clean Core Assessment Checklist

Legacy Software Modernization Strategy

Expertise: Built from hands-on SAP BTP projects: complete working code, deployment configuration, and troubleshooting patterns verified in production S/4HANA environments.


Download the full source code and adapt the patterns for your own SAP BTP side-by-side extension.

SAP Clean Core Assessment Checklist

Legacy Software Modernization Strategy

Expertise: Built by SAP BTP architects who have delivered side-by-side extensions for enterprise S/4HANA migrations; includes production-tested code patterns and deployment configurations.


Start building your SAP BTP Side-by-Side Extension: Example today—download the source code and deploy your first Clean Core extension.

Frequently Asked Questions

What is a side-by-side extension in SAP BTP?

A side-by-side extension is an application that runs independently on SAP BTP and communicates with S/4HANA through public APIs, extending core business processes without modifying the ERP system.

How does SAP BTP integrate with S/4HANA?

SAP BTP integrates with S/4HANA through public APIs such as OData, REST, or SOAP, allowing external applications to read and write data while keeping the core system untouched.

What is the MTA build tool used for in SAP BTP?

The MTA (Multi-Target Application) build tool packages applications and their dependencies into a deployable archive for SAP BTP Cloud Foundry or Kyma runtime environments.

Do side-by-side extensions affect S/4HANA upgrades?

No, side-by-side extensions do not affect S/4HANA upgrades because they run externally on SAP BTP and avoid direct modifications to the core system, preserving upgrade paths.

What programming languages can you use for SAP BTP side-by-side extensions?

You can use Node.js, Java, Python, or Go for the backend, and React, Angular, or SAP UI5 for the frontend. The example in this guide uses Node.js with CAP and React with TypeScript.

How do you handle authentication between SAP BTP and S/4HANA?

Authentication is handled through SAP BTP's XSUAA service and the Destination service, which securely manages OAuth 2.0 tokens and SAML assertions for S/4HANA connectivity.

Related Topics

#SAP BTP #Side-by-Side Extension #SAP S/4HANA #Cloud Foundry #Clean Core #CAP #Node.js #React
Florian Strauf

About Florian Strauf

Experienced fractional CTO and technical consultant helping New Zealand startups and businesses accelerate their technology initiatives. Specializing in MVP development, technical due diligence, and strategic technology guidance.