Zum Inhalt springen
Frontend

Next.js App Router und React Server Components - Ein vollstaendiger Leitfaden

Veröffentlicht am:
·5 Min. Lesezeit·Autor: MDS Software Solutions Group

Next.js App Router

frontend

Next.js App Router und React Server Components - Ein vollstaendiger Leitfaden

Next.js 13 hat den App Router eingefuehrt -- ein neues Paradigma fuer die Erstellung von Anwendungen auf Basis von React Server Components. Diese Architektur veraendert grundlegend, wie wir ueber Rendering, Datenabruf und Code-Organisation in React-Anwendungen denken. In diesem Leitfaden werden wir jeden Aspekt des App Routers ausfuehrlich behandeln und zeigen, wie Sie sein volles Potenzial in Produktionsprojekten ausschoepfen koennen.

App Router vs Pages Router -- Was hat sich geaendert?#

Der Pages Router, basierend auf dem pages/-Verzeichnis, war jahrelang das Standard-Routing-Modell in Next.js. Der App Router, der das app/-Verzeichnis verwendet, fuehrt einen voellig neuen Ansatz ein.

Wichtige Unterschiede#

| Merkmal | Pages Router | App Router | |---|---|---| | Verzeichnis | pages/ | app/ | | Standard-Rendering | Client Components | Server Components | | Datenabruf | getServerSideProps, getStaticProps | async Komponenten | | Layouts | Manuell, pro Seite | Verschachtelt, geteilt | | Ladezustaende | Manuell | loading.tsx | | Fehlerbehandlung | _error.tsx | error.tsx pro Segment | | Metadaten | Head Komponente | Metadata API |

Im App Router wird jede Route durch eine Ordnerstruktur definiert, und spezielle Dateien (page.tsx, layout.tsx, loading.tsx, error.tsx) steuern das Verhalten der einzelnen Segmente.

app/
├── layout.tsx          # Root Layout
├── page.tsx            # Startseite (/)
├── loading.tsx         # Lade-UI fuer /
├── error.tsx           # Fehler-UI fuer /
├── blog/
│   ├── layout.tsx      # Blog-Layout
│   ├── page.tsx        # /blog
│   └── [slug]/
│       ├── page.tsx    # /blog/:slug
│       └── loading.tsx # Laden fuer einzelnen Beitrag
└── dashboard/
    ├── layout.tsx      # Dashboard-Layout
    ├── page.tsx        # /dashboard
    └── settings/
        └── page.tsx    # /dashboard/settings

Server Components vs Client Components#

React Server Components (RSC) stellen einen fundamentalen Wandel in der React-Architektur dar. Im App Router sind alle Komponenten standardmaessig Server Components.

Server Components -- Vorteile und Einschraenkungen#

Server Components werden ausschliesslich auf dem Server gerendert. Ihr JavaScript-Code erreicht niemals den Browser, was eine erhebliche Reduzierung der Bundle-Groesse bedeutet.

// app/products/page.tsx
// Dies ist eine Server Component -- Standard im App Router

import { db } from '@/lib/database';

export default async function ProductsPage() {
  // Direkter Datenbankzugriff -- kein API noetig!
  const products = await db.product.findMany({
    where: { isPublished: true },
    orderBy: { createdAt: 'desc' },
  });

  return (
    <main>
      <h1>Unsere Produkte</h1>
      <div className="grid grid-cols-3 gap-6">
        {products.map((product) => (
          <article key={product.id} className="border rounded-lg p-4">
            <h2 className="text-xl font-bold">{product.name}</h2>
            <p className="text-gray-600">{product.description}</p>
            <span className="text-2xl font-bold text-green-600">
              {product.price.toFixed(2)} EUR
            </span>
          </article>
        ))}
      </div>
    </main>
  );
}

Vorteile von Server Components:

  • Direkter Zugriff auf Datenbanken, Dateisysteme und Server-Ressourcen
  • Kein Einfluss auf die Client-Bundle-Groesse
  • Automatisches Verbergen sensibler Daten (API-Schluessel, Tokens)
  • Bessere Erstlade-Performance (weniger JavaScript)

Einschraenkungen von Server Components:

  • Kein Zugriff auf useState, useEffect oder andere React-Hooks
  • Keine Browser-Event-Handler (onClick, onChange)
  • Kein Zugriff auf Browser-Web-APIs (window, localStorage)

Die 'use client'-Direktive#

Wenn Sie Interaktivitaet benoetigen, verwenden Sie die 'use client'-Direktive am Anfang der Datei. Diese Grenze teilt Next.js mit, dass die Komponente (und ihre Kinder) auch auf der Client-Seite gerendert werden sollen.

'use client';

// app/components/AddToCartButton.tsx
import { useState, useTransition } from 'react';
import { addToCart } from '@/actions/cart';

interface AddToCartButtonProps {
  productId: string;
  productName: string;
}

export function AddToCartButton({ productId, productName }: AddToCartButtonProps) {
  const [quantity, setQuantity] = useState(1);
  const [isPending, startTransition] = useTransition();
  const [message, setMessage] = useState('');

  const handleAddToCart = () => {
    startTransition(async () => {
      const result = await addToCart(productId, quantity);
      if (result.success) {
        setMessage(`${quantity}x ${productName} zum Warenkorb hinzugefuegt!`);
      }
    });
  };

  return (
    <div className="flex items-center gap-4">
      <select
        value={quantity}
        onChange={(e) => setQuantity(Number(e.target.value))}
        className="border rounded p-2"
      >
        {[1, 2, 3, 4, 5].map((n) => (
          <option key={n} value={n}>{n}</option>
        ))}
      </select>
      <button
        onClick={handleAddToCart}
        disabled={isPending}
        className="bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
      >
        {isPending ? 'Wird hinzugefuegt...' : 'In den Warenkorb'}
      </button>
      {message && <p className="text-green-600">{message}</p>}
    </div>
  );
}

Kompositionsmuster: Server + Client Components#

Die beste Vorgehensweise ist, Client Components so weit unten im Komponentenbaum wie moeglich zu halten. Eine Server Component kann eine Client Component als Kind rendern und Daten ueber Props uebergeben.

// app/products/[id]/page.tsx -- Server Component
import { db } from '@/lib/database';
import { AddToCartButton } from '@/components/AddToCartButton';
import { ProductGallery } from '@/components/ProductGallery';

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await db.product.findUnique({
    where: { id: params.id },
    include: { images: true, reviews: true },
  });

  if (!product) {
    notFound();
  }

  return (
    <main className="max-w-6xl mx-auto py-8">
      <div className="grid grid-cols-2 gap-8">
        {/* Client Component -- interaktive Galerie */}
        <ProductGallery images={product.images} />

        <div>
          <h1 className="text-3xl font-bold">{product.name}</h1>
          <p className="text-gray-600 mt-4">{product.description}</p>
          <p className="text-3xl font-bold text-green-600 mt-4">
            {product.price.toFixed(2)} EUR
          </p>

          {/* Client Component -- interaktiver Button */}
          <AddToCartButton
            productId={product.id}
            productName={product.name}
          />
        </div>
      </div>

      {/* Server Component -- statische Bewertungsliste */}
      <section className="mt-12">
        <h2 className="text-2xl font-bold">Bewertungen ({product.reviews.length})</h2>
        {product.reviews.map((review) => (
          <div key={review.id} className="border-b py-4">
            <p className="font-semibold">{review.author}</p>
            <p>{review.content}</p>
          </div>
        ))}
      </section>
    </main>
  );
}

Datenabruf -- Async Components#

Im App Router ist der Datenabruf radikal vereinfacht. Anstelle spezieller Funktionen wie getServerSideProps koennen Server-Komponenten async sein und direkt await verwenden.

// app/dashboard/page.tsx
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';

async function getAnalytics(userId: string) {
  const res = await fetch(`${process.env.API_URL}/analytics/${userId}`, {
    next: { revalidate: 300 }, // Revalidierung alle 5 Minuten
  });
  if (!res.ok) throw new Error('Analytics konnten nicht abgerufen werden');
  return res.json();
}

async function getRecentOrders(userId: string) {
  const res = await fetch(`${process.env.API_URL}/orders/${userId}`, {
    next: { revalidate: 60 }, // Revalidierung jede Minute
  });
  if (!res.ok) throw new Error('Bestellungen konnten nicht abgerufen werden');
  return res.json();
}

export default async function DashboardPage() {
  const session = await getServerSession(authOptions);
  if (!session) redirect('/login');

  // Paralleler Datenabruf
  const [analytics, recentOrders] = await Promise.all([
    getAnalytics(session.user.id),
    getRecentOrders(session.user.id),
  ]);

  return (
    <div className="space-y-8">
      <h1>Willkommen, {session.user.name}!</h1>
      <AnalyticsCards data={analytics} />
      <RecentOrdersTable orders={recentOrders} />
    </div>
  );
}

Request-Deduplizierung#

Next.js dedupliziert automatisch fetch-Anfragen mit denselben Argumenten innerhalb eines einzelnen Render-Durchlaufs. Wenn mehrere Komponenten dieselben Daten abrufen, wird die Anfrage nur einmal ausgefuehrt.

// Diese beiden Komponenten im selben Render-Baum
// fuehren nur EINE Anfrage an die API aus

// app/components/Header.tsx
async function Header() {
  const user = await fetch('/api/user'); // <- dedupliziert
  return <nav>{user.name}</nav>;
}

// app/components/Sidebar.tsx
async function Sidebar() {
  const user = await fetch('/api/user'); // <- dedupliziert
  return <aside>{user.email}</aside>;
}

loading.tsx und error.tsx -- Zustandsverwaltung#

Ladezustaende#

Die Datei loading.tsx umschliesst die Seite automatisch mit einer React Suspense-Boundary und zeigt einen Fallback an, waehrend die Daten geladen werden.

// app/blog/loading.tsx
export default function BlogLoading() {
  return (
    <div className="space-y-4">
      {Array.from({ length: 6 }).map((_, i) => (
        <div key={i} className="animate-pulse">
          <div className="h-4 bg-gray-200 rounded w-3/4 mb-2" />
          <div className="h-3 bg-gray-200 rounded w-1/2" />
        </div>
      ))}
    </div>
  );
}

Fehlerbehandlung#

Die Datei error.tsx faengt Fehler innerhalb eines bestimmten Route-Segments ab und zeigt eine Fallback-UI an. Sie muss eine Client Component sein, da sie Hooks verwendet.

'use client';

// app/blog/error.tsx
export default function BlogError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="text-center py-12">
      <h2 className="text-2xl font-bold text-red-600">Etwas ist schiefgelaufen!</h2>
      <p className="text-gray-600 mt-2">{error.message}</p>
      <button
        onClick={() => reset()}
        className="mt-4 bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700"
      >
        Erneut versuchen
      </button>
    </div>
  );
}

Parallel Routes und Intercepting Routes#

Parallel Routes (Parallele Routen)#

Parallel Routes ermoeglichen es, mehrere Seiten gleichzeitig innerhalb desselben Layouts zu rendern. Sie werden mithilfe benannter Slots definiert -- Ordner mit dem Praefix @.

app/dashboard/
├── layout.tsx
├── page.tsx
├── @analytics/
│   ├── page.tsx
│   └── loading.tsx
├── @activity/
│   ├── page.tsx
│   └── loading.tsx
└── @notifications/
    └── page.tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  activity,
  notifications,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  activity: React.ReactNode;
  notifications: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-12 gap-6">
      <main className="col-span-8">{children}</main>
      <aside className="col-span-4 space-y-6">
        {analytics}
        {activity}
        {notifications}
      </aside>
    </div>
  );
}

Jeder Slot laedt unabhaengig, hat seine eigene loading.tsx und error.tsx, was unabhaengiges Streaming verschiedener Seitenbereiche ermoeglicht.

Intercepting Routes (Abfangende Routen)#

Intercepting Routes ermoeglichen es, die Navigation zu einer Route abzufangen und ihren Inhalt im Kontext der aktuellen Seite anzuzeigen -- ein perfektes Muster fuer Modale.

app/
├── feed/
│   ├── page.tsx
│   └── (..)photo/[id]/     # Faengt /photo/[id] ab
│       └── page.tsx         # Wird als Modal angezeigt
└── photo/[id]/
    └── page.tsx             # Vollstaendige Seite (Direktzugriff / Aktualisierung)

Abfang-Konventionen:

  • (.) -- gleiche Ebene
  • (..) -- eine Ebene hoeher
  • (..)(..) -- zwei Ebenen hoeher
  • (...) -- vom App-Root

Server Actions#

Server Actions sind asynchrone Funktionen, die auf dem Server ausgefuehrt und direkt aus Komponenten aufgerufen werden. Sie machen separate API-Endpunkte fuer Datenmutationen ueberfluessig.

// app/actions/contact.ts
'use server';

import { z } from 'zod';
import { db } from '@/lib/database';
import { revalidatePath } from 'next/cache';

const ContactSchema = z.object({
  name: z.string().min(2, 'Der Name muss mindestens 2 Zeichen lang sein'),
  email: z.string().email('Ungueltige E-Mail-Adresse'),
  message: z.string().min(10, 'Die Nachricht muss mindestens 10 Zeichen lang sein'),
});

export type ContactFormState = {
  errors?: Record<string, string[]>;
  success?: boolean;
  message?: string;
};

export async function submitContactForm(
  prevState: ContactFormState,
  formData: FormData
): Promise<ContactFormState> {
  const rawData = {
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  };

  const validated = ContactSchema.safeParse(rawData);

  if (!validated.success) {
    return {
      errors: validated.error.flatten().fieldErrors,
      success: false,
      message: 'Bitte korrigieren Sie die Fehler im Formular.',
    };
  }

  try {
    await db.contactMessage.create({
      data: validated.data,
    });

    revalidatePath('/admin/messages');

    return {
      success: true,
      message: 'Vielen Dank! Ihre Nachricht wurde gesendet.',
    };
  } catch (error) {
    return {
      success: false,
      message: 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es spaeter erneut.',
    };
  }
}
'use client';

// app/contact/ContactForm.tsx
import { useActionState } from 'react';
import { submitContactForm, type ContactFormState } from '@/actions/contact';

export function ContactForm() {
  const [state, formAction, isPending] = useActionState<ContactFormState, FormData>(
    submitContactForm,
    { success: false }
  );

  if (state.success) {
    return (
      <div className="bg-green-50 border border-green-200 rounded-lg p-6">
        <p className="text-green-800 font-semibold">{state.message}</p>
      </div>
    );
  }

  return (
    <form action={formAction} className="space-y-4">
      <div>
        <label htmlFor="name" className="block font-medium">Name</label>
        <input
          id="name"
          name="name"
          type="text"
          className="w-full border rounded p-2 mt-1"
        />
        {state.errors?.name && (
          <p className="text-red-500 text-sm mt-1">{state.errors.name[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="email" className="block font-medium">E-Mail</label>
        <input
          id="email"
          name="email"
          type="email"
          className="w-full border rounded p-2 mt-1"
        />
        {state.errors?.email && (
          <p className="text-red-500 text-sm mt-1">{state.errors.email[0]}</p>
        )}
      </div>

      <div>
        <label htmlFor="message" className="block font-medium">Nachricht</label>
        <textarea
          id="message"
          name="message"
          rows={5}
          className="w-full border rounded p-2 mt-1"
        />
        {state.errors?.message && (
          <p className="text-red-500 text-sm mt-1">{state.errors.message[0]}</p>
        )}
      </div>

      <button
        type="submit"
        disabled={isPending}
        className="bg-blue-600 text-white px-8 py-3 rounded-lg hover:bg-blue-700 disabled:opacity-50"
      >
        {isPending ? 'Wird gesendet...' : 'Nachricht senden'}
      </button>

      {state.message && !state.success && (
        <p className="text-red-500">{state.message}</p>
      )}
    </form>
  );
}

Streaming mit Suspense#

Streaming ermoeglicht progressives UI-Rendering -- fertige Teile der Seite werden sofort angezeigt, waehrend langsamere Abschnitte weiter laden.

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { RevenueChart } from '@/components/RevenueChart';
import { LatestOrders } from '@/components/LatestOrders';
import { TopProducts } from '@/components/TopProducts';

export default function DashboardPage() {
  return (
    <div className="space-y-8">
      <h1 className="text-3xl font-bold">Dashboard</h1>

      {/* Dieser Abschnitt laedt sofort */}
      <div className="grid grid-cols-3 gap-4">
        <StatCard title="Umsatz" value="125.430 EUR" />
        <StatCard title="Bestellungen" value="1.234" />
        <StatCard title="Kunden" value="856" />
      </div>

      {/* Diese Abschnitte streamen unabhaengig */}
      <div className="grid grid-cols-2 gap-6">
        <Suspense fallback={<ChartSkeleton />}>
          <RevenueChart />
        </Suspense>

        <Suspense fallback={<TableSkeleton rows={5} />}>
          <LatestOrders />
        </Suspense>
      </div>

      <Suspense fallback={<GridSkeleton />}>
        <TopProducts />
      </Suspense>
    </div>
  );
}

// Asynchrone Komponente -- gestreamt
async function RevenueChart() {
  const data = await fetch('/api/analytics/revenue', {
    next: { revalidate: 3600 },
  }).then((r) => r.json());

  return (
    <div className="border rounded-lg p-6">
      <h2 className="text-xl font-bold mb-4">Umsatz</h2>
      <Chart data={data} type="line" />
    </div>
  );
}

Caching und Revalidierung#

Der Next.js App Router bietet ein mehrstufiges Caching-System.

Caching-Strategien mit fetch#

// Daten dauerhaft gecacht (Standard)
const staticData = await fetch('https://api.example.com/config');

// Zeitbasierte Revalidierung (ISR) -- alle 60 Sekunden
const timedData = await fetch('https://api.example.com/products', {
  next: { revalidate: 60 },
});

// Kein Caching -- immer frische Daten
const dynamicData = await fetch('https://api.example.com/stock', {
  cache: 'no-store',
});

// Tag-basierte Revalidierung
const taggedData = await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] },
});

On-Demand-Revalidierung#

// app/actions/blog.ts
'use server';

import { revalidatePath, revalidateTag } from 'next/cache';

export async function publishPost(postId: string) {
  await db.post.update({
    where: { id: postId },
    data: { status: 'published' },
  });

  // Einen bestimmten Pfad revalidieren
  revalidatePath('/blog');
  revalidatePath(`/blog/${postId}`);

  // Oder nach Tag revalidieren
  revalidateTag('posts');
}

Route-Segment-Konfiguration#

// app/dashboard/page.tsx

// Dynamisches Rendering erzwingen
export const dynamic = 'force-dynamic';

// Oder Revalidierung fuer das gesamte Segment konfigurieren
export const revalidate = 30; // Sekunden

Metadata API#

Der App Router fuehrt ein deklaratives Metadaten-Verwaltungssystem ein, das fuer SEO unverzichtbar ist.

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { db } from '@/lib/database';

// Statische Metadaten
export const metadata: Metadata = {
  title: 'Blog',
};

// Oder dynamische Metadaten
export async function generateMetadata({
  params,
}: {
  params: { slug: string };
}): Promise<Metadata> {
  const post = await db.post.findUnique({
    where: { slug: params.slug },
  });

  if (!post) {
    return { title: 'Beitrag nicht gefunden' };
  }

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [
        {
          url: post.coverImage,
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
      type: 'article',
      publishedTime: post.publishedAt.toISOString(),
      authors: [post.author.name],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
    alternates: {
      canonical: `https://example.com/blog/${post.slug}`,
    },
  };
}

Migrationsleitfaden vom Pages Router#

Die Migration vom Pages Router zum App Router muss nicht auf einmal erfolgen. Beide Systeme koennen innerhalb desselben Projekts koexistieren.

Schritt 1: Erstellen Sie das app/-Verzeichnis und das Root-Layout#

// app/layout.tsx
import { Inter } from 'next/font/google';
import '@/styles/globals.css';

const inter = Inter({ subsets: ['latin'] });

export const metadata = {
  title: {
    default: 'Meine Anwendung',
    template: '%s | Meine Anwendung',
  },
  description: 'Beschreibung der Anwendung',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="de">
      <body className={inter.className}>
        <Header />
        {children}
        <Footer />
      </body>
    </html>
  );
}

Schritt 2: Datenabruf migrieren#

// VORHER: pages/products.tsx
export async function getServerSideProps() {
  const products = await fetchProducts();
  return { props: { products } };
}

export default function ProductsPage({ products }) {
  return <ProductList products={products} />;
}

// NACHHER: app/products/page.tsx
export default async function ProductsPage() {
  const products = await fetchProducts();
  return <ProductList products={products} />;
}

Schritt 3: Schrittweise migrieren#

  1. Beginnen Sie mit einfachen, zustandslosen Seiten
  2. Verschieben Sie gemeinsame Layouts nach app/layout.tsx
  3. Migrieren Sie API Routes zu Route Handlers (app/api/*/route.ts)
  4. Migrieren Sie zuletzt Seiten mit komplexem Zustandsmanagement

Schritt 4: Route Handlers statt API Routes#

// VORHER: pages/api/products.ts
export default function handler(req, res) {
  if (req.method === 'GET') {
    const products = await db.product.findMany();
    res.json(products);
  }
}

// NACHHER: app/api/products/route.ts
import { NextResponse } from 'next/server';

export async function GET() {
  const products = await db.product.findMany();
  return NextResponse.json(products);
}

export async function POST(request: Request) {
  const body = await request.json();
  const product = await db.product.create({ data: body });
  return NextResponse.json(product, { status: 201 });
}

Best Practices#

  1. Standardmaessig Server Components -- verwenden Sie 'use client' nur dort, wo Sie wirklich Interaktivitaet benoetigen.
  2. Client Components nach unten verschieben -- je weniger Ihrer App eine Client Component ist, desto kleiner das Bundle.
  3. Paralleler Datenabruf -- verwenden Sie Promise.all() statt sequenzieller await-Aufrufe.
  4. Granulare Suspense-Boundaries -- jeder unabhaengige Abschnitt sollte seine eigene Suspense-Boundary haben.
  5. Bewusstes Caching -- waehlen Sie Revalidierungsstrategien, die zur Art Ihrer Daten passen.
  6. Server Actions statt API Routes -- fuer Datenmutationen innerhalb Ihrer Anwendung.
  7. Metadata API -- definieren Sie immer Metadaten fuer SEO.

Fazit#

Der App Router und React Server Components stellen einen bahnbrechenden Wandel im React-Oekosystem dar. Das neue Modell bietet bessere Performance, einfacheren Datenabruf, eingebaute Lade- und Fehlerzustandsbehandlung sowie fortgeschrittene Routing-Muster. Obwohl die Lernkurve steil ist, machen die Vorteile -- kleinere Bundles, schnelleres Laden und bessere Architektur -- dies mehr als wett.


Brauchen Sie Hilfe bei der Implementierung des Next.js App Routers in Ihrem Projekt? Das Team von MDS Software Solutions Group ist auf die Entwicklung leistungsstarker Webanwendungen mit den neuesten Technologien spezialisiert. Kontaktieren Sie uns, um zu besprechen, wie wir Ihre Projektentwicklung beschleunigen und Best Practices von Anfang an implementieren koennen.

Autor
MDS Software Solutions Group

Team von Programmierexperten, die sich auf moderne Webtechnologien spezialisiert haben.

Next.js App Router und React Server Components - Ein vollstaendiger Leitfaden | MDS Software Solutions Group | MDS Software Solutions Group