Integration Guide: Next.js + Go Courses App

Step-by-step guide to integrate MediaKit into a courses platform built with Next.js (frontend) and Go (backend). We'll set up video streaming for course lessons and optimized image delivery for thumbnails.

What you'll build

  • - Course video uploads with presigned URLs (direct to R2/S3)
  • - HLS adaptive streaming for lessons (360p–1080p)
  • - Optimized thumbnails with on-demand transforms
  • - Private video access for paid courses (signed URLs)
  • - Upload progress tracking in the admin
  • - AI-generated chapter markers for long videos

Part 1 — Setup

1.1 Install the SDK

bash
cd your-nextjs-app
npm install @mediakit-dev/react

1.2 Get an API Key

In your MediaKit admin panel (mediakitadmin.gritcms.com):

  1. Go to API Keys in the sidebar
  2. Click Create
  3. Name: Courses App
  4. Scopes: read:assets, write:assets, read:analytics
  5. Copy the key — it starts with mk_live_

1.3 Environment Variables

your-nextjs-app/.env.local
# Public — used in browser (React SDK)
NEXT_PUBLIC_MEDIAKIT_URL=https://mediakitapi.gritcms.com

# Private — used server-side only (Go backend or Next.js API routes)
MEDIAKIT_API_KEY=mk_live_your_key_here
MEDIAKIT_SIGNED_URL_SECRET=your_signed_url_secret

1.4 Set Up the Provider

Wrap your app (or just the courses section) with MediaKitProvider:

app/providers.tsx
'use client'

import { MediaKitProvider } from '@mediakit-dev/react'

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <MediaKitProvider
      config={{
        baseUrl: process.env.NEXT_PUBLIC_MEDIAKIT_URL!,
      }}
    >
      {children}
    </MediaKitProvider>
  )
}
app/layout.tsx
import { Providers } from './providers'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

Part 2 — Course Video Uploads

2.1 Upload Page (Admin/Instructor)

Create a page where instructors upload lesson videos:

app/admin/courses/[courseId]/lessons/[lessonId]/upload/page.tsx
'use client'

import { useState } from 'react'

const MEDIAKIT_URL = process.env.NEXT_PUBLIC_MEDIAKIT_URL

export default function UploadLessonVideo({
  params
}: {
  params: { courseId: string; lessonId: string }
}) {
  const [progress, setProgress] = useState(0)
  const [status, setStatus] = useState<'idle' | 'uploading' | 'processing' | 'done' | 'error'>('idle')
  const [assetId, setAssetId] = useState<number | null>(null)

  async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0]
    if (!file) return

    setStatus('uploading')

    try {
      // Step 1: Get presigned URL from your backend
      const presignRes = await fetch('/api/upload/presign', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          filename: file.name,
          content_type: file.type,
          file_size: file.size,
          title: `Lesson ${params.lessonId} - ${file.name}`,
          course_id: params.courseId,
          lesson_id: params.lessonId,
        }),
      }).then(r => r.json())

      const { upload_url, asset_id, key } = presignRes

      setAssetId(asset_id)

      // Step 2: Upload directly to R2/S3 with progress
      await new Promise<void>((resolve, reject) => {
        const xhr = new XMLHttpRequest()
        xhr.open('PUT', upload_url)
        xhr.setRequestHeader('Content-Type', file.type)

        xhr.upload.onprogress = (e) => {
          if (e.lengthComputable) {
            setProgress(Math.round((e.loaded / e.total) * 100))
          }
        }

        xhr.onload = () => xhr.status === 200 ? resolve() : reject()
        xhr.onerror = reject
        xhr.send(file)
      })

      // Step 3: Confirm upload (triggers transcoding)
      await fetch('/api/upload/complete', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ asset_id, key }),
      })

      setStatus('processing')

      // Step 4: Poll for transcode completion
      const poll = setInterval(async () => {
        const res = await fetch(
          `${MEDIAKIT_URL}/api/mediakit/videos/${asset_id}/playback`
        ).then(r => r.json())

        if (res.data?.transcode_status === 'ready') {
          clearInterval(poll)
          setStatus('done')

          // Save the asset_id to your lesson in your database
          await fetch(`/api/courses/${params.courseId}/lessons/${params.lessonId}`, {
            method: 'PATCH',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ video_asset_id: asset_id }),
          })
        }
      }, 3000)
    } catch (err) {
      setStatus('error')
    }
  }

  return (
    <div className="max-w-xl mx-auto py-12">
      <h1 className="text-2xl font-bold mb-6">Upload Lesson Video</h1>

      <input
        type="file"
        accept="video/mp4,video/webm,video/quicktime"
        onChange={handleUpload}
        disabled={status !== 'idle'}
        className="mb-4"
      />

      {status === 'uploading' && (
        <div>
          <div className="w-full h-3 bg-gray-200 rounded-full overflow-hidden">
            <div
              className="h-full bg-blue-600 transition-all"
              style={{ width: `${progress}%` }}
            />
          </div>
          <p className="text-sm text-gray-500 mt-2">Uploading... {progress}%</p>
        </div>
      )}

      {status === 'processing' && (
        <div className="flex items-center gap-2 text-yellow-600">
          <div className="h-4 w-4 border-2 border-yellow-600 border-t-transparent rounded-full animate-spin" />
          Transcoding to HLS (360p–1080p)...
        </div>
      )}

      {status === 'done' && (
        <div className="text-green-600 font-medium">
          ✓ Video ready! Asset ID: {assetId}
        </div>
      )}

      {status === 'error' && (
        <div className="text-red-600">Upload failed. Please try again.</div>
      )}
    </div>
  )
}

2.2 Backend API Routes (Next.js)

These server-side routes talk to MediaKit with your API key (never exposed to browser):

app/api/upload/presign/route.ts
import { NextRequest, NextResponse } from 'next/server'

const MEDIAKIT_URL = process.env.NEXT_PUBLIC_MEDIAKIT_URL
const API_KEY = process.env.MEDIAKIT_API_KEY

export async function POST(req: NextRequest) {
  // TODO: Verify the user is an instructor/admin
  const body = await req.json()

  const res = await fetch(`${MEDIAKIT_URL}/api/mediakit/uploads/presign`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': API_KEY!,
    },
    body: JSON.stringify({
      filename: body.filename,
      content_type: body.content_type,
      file_size: body.file_size,
      asset_type: 'video',
      title: body.title,
    }),
  })

  const data = await res.json()
  return NextResponse.json(data.data)
}
app/api/upload/complete/route.ts
import { NextRequest, NextResponse } from 'next/server'

const MEDIAKIT_URL = process.env.NEXT_PUBLIC_MEDIAKIT_URL
const API_KEY = process.env.MEDIAKIT_API_KEY

export async function POST(req: NextRequest) {
  const body = await req.json()

  const res = await fetch(`${MEDIAKIT_URL}/api/mediakit/uploads/complete`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': API_KEY!,
    },
    body: JSON.stringify({
      asset_id: body.asset_id,
      key: body.key,
    }),
  })

  const data = await res.json()
  return NextResponse.json(data)
}

2.3 Go Backend Alternative

If your backend is Go, here's the equivalent presign proxy:

internal/handlers/media.go
package handlers

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"

	"github.com/gin-gonic/gin"
)

var (
	mediakitURL = os.Getenv("MEDIAKIT_URL")    // https://mediakitapi.gritcms.com
	mediakitKey = os.Getenv("MEDIAKIT_API_KEY") // mk_live_...
)

type PresignRequest struct {
	Filename    string `json:"filename"`
	ContentType string `json:"content_type"`
	FileSize    int64  `json:"file_size"`
	Title       string `json:"title"`
	AssetType   string `json:"asset_type"`
}

// POST /api/media/presign
func PresignUpload(c *gin.Context) {
	var req PresignRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(400, gin.H{"error": "invalid request"})
		return
	}

	// Default to video if not specified
	if req.AssetType == "" {
		req.AssetType = "video"
	}

	body, _ := json.Marshal(req)

	resp, err := http.Post(
		mediakitURL+"/api/mediakit/uploads/presign",
		"application/json",
		bytes.NewReader(body),
	)
	if err != nil {
		c.JSON(502, gin.H{"error": "mediakit unreachable"})
		return
	}
	defer resp.Body.Close()

	// Add API key header (use custom transport in production)
	client := &http.Client{}
	httpReq, _ := http.NewRequest("POST",
		mediakitURL+"/api/mediakit/uploads/presign",
		bytes.NewReader(body),
	)
	httpReq.Header.Set("Content-Type", "application/json")
	httpReq.Header.Set("X-API-Key", mediakitKey)

	resp, err = client.Do(httpReq)
	if err != nil {
		c.JSON(502, gin.H{"error": "mediakit unreachable"})
		return
	}
	defer resp.Body.Close()

	data, _ := io.ReadAll(resp.Body)
	c.Data(resp.StatusCode, "application/json", data)
}

type CompleteRequest struct {
	AssetID int    `json:"asset_id"`
	Key     string `json:"key"`
}

// POST /api/media/complete
func CompleteUpload(c *gin.Context) {
	var req CompleteRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(400, gin.H{"error": "invalid request"})
		return
	}

	body, _ := json.Marshal(req)

	client := &http.Client{}
	httpReq, _ := http.NewRequest("POST",
		mediakitURL+"/api/mediakit/uploads/complete",
		bytes.NewReader(body),
	)
	httpReq.Header.Set("Content-Type", "application/json")
	httpReq.Header.Set("X-API-Key", mediakitKey)

	resp, err := client.Do(httpReq)
	if err != nil {
		c.JSON(502, gin.H{"error": "mediakit unreachable"})
		return
	}
	defer resp.Body.Close()

	data, _ := io.ReadAll(resp.Body)
	c.Data(resp.StatusCode, "application/json", data)
}

// GET /api/media/playback/:id
func GetPlayback(c *gin.Context) {
	id := c.Param("id")

	resp, err := http.Get(
		fmt.Sprintf("%s/api/mediakit/videos/%s/playback", mediakitURL, id),
	)
	if err != nil {
		c.JSON(502, gin.H{"error": "mediakit unreachable"})
		return
	}
	defer resp.Body.Close()

	data, _ := io.ReadAll(resp.Body)
	c.Data(resp.StatusCode, "application/json", data)
}

// POST /api/media/signed-url
// For paid courses — generates a time-limited URL
func GetSignedURL(c *gin.Context) {
	// TODO: Check if user has purchased the course
	var req struct {
		AssetID   int    `json:"asset_id"`
		ExpiresIn int    `json:"expires_in"`
		IP        string `json:"ip"`
	}
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(400, gin.H{"error": "invalid request"})
		return
	}

	if req.ExpiresIn == 0 {
		req.ExpiresIn = 7200 // 2 hours default
	}
	if req.IP == "" {
		req.IP = c.ClientIP()
	}

	body, _ := json.Marshal(req)

	client := &http.Client{}
	httpReq, _ := http.NewRequest("POST",
		mediakitURL+"/api/mediakit/signed-url",
		bytes.NewReader(body),
	)
	httpReq.Header.Set("Content-Type", "application/json")
	httpReq.Header.Set("X-API-Key", mediakitKey)

	resp, err := client.Do(httpReq)
	if err != nil {
		c.JSON(502, gin.H{"error": "mediakit unreachable"})
		return
	}
	defer resp.Body.Close()

	data, _ := io.ReadAll(resp.Body)
	c.Data(resp.StatusCode, "application/json", data)
}
internal/routes/routes.go (add these)
// MediaKit proxy routes
media := r.Group("/api/media")
media.Use(authMiddleware) // your auth middleware
{
    media.POST("/presign", handlers.PresignUpload)
    media.POST("/complete", handlers.CompleteUpload)
    media.GET("/playback/:id", handlers.GetPlayback)
    media.POST("/signed-url", handlers.GetSignedURL)
}

Part 3 — Lesson Video Player

3.1 Public Lessons (Free)

For free lessons, use the VideoPlayer component directly:

app/courses/[courseId]/lessons/[lessonId]/page.tsx
'use client'

import { VideoPlayer } from '@mediakit-dev/react'

interface Lesson {
  id: string
  title: string
  video_asset_id: number
  is_free: boolean
}

export default function LessonPage({
  lesson
}: {
  lesson: Lesson
}) {
  return (
    <div className="max-w-4xl mx-auto">
      <h1 className="text-2xl font-bold mb-4">{lesson.title}</h1>

      {/* VideoPlayer fetches HLS URL from MediaKit automatically */}
      <VideoPlayer
        videoId={String(lesson.video_asset_id)}
        controls
        className="rounded-xl overflow-hidden aspect-video"
      />
    </div>
  )
}

3.2 Paid Lessons (Signed URLs)

For paid courses, generate a signed URL server-side, then pass it to the player:

app/courses/[courseId]/lessons/[lessonId]/page.tsx (paid)
'use client'

import { VideoPlayer } from '@mediakit-dev/react'
import { useEffect, useState } from 'react'

export default function PaidLessonPage({
  lesson,
  hasPurchased,
}: {
  lesson: { id: string; title: string; video_asset_id: number }
  hasPurchased: boolean
}) {
  const [signedUrl, setSignedUrl] = useState<string | null>(null)

  useEffect(() => {
    if (!hasPurchased) return

    // Get signed URL from your backend
    fetch('/api/media/signed-url', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        asset_id: lesson.video_asset_id,
        expires_in: 7200, // 2 hours
      }),
    })
      .then(r => r.json())
      .then(data => setSignedUrl(data.data?.url))
  }, [lesson.video_asset_id, hasPurchased])

  if (!hasPurchased) {
    return (
      <div className="aspect-video bg-gray-900 rounded-xl flex items-center justify-center">
        <div className="text-center">
          <p className="text-white text-lg font-medium">Premium Content</p>
          <p className="text-gray-400 mt-1">Purchase this course to watch</p>
          <button className="mt-4 bg-blue-600 text-white px-6 py-2 rounded-lg">
            Buy Course
          </button>
        </div>
      </div>
    )
  }

  if (!signedUrl) {
    return <div className="aspect-video bg-gray-900 rounded-xl animate-pulse" />
  }

  return (
    <VideoPlayer
      src={signedUrl}
      controls
      className="rounded-xl overflow-hidden aspect-video"
    />
  )
}

Part 4 — Course Thumbnails & Images

4.1 Upload Thumbnails

Same upload flow but with asset_type: "image":

app/admin/courses/[courseId]/thumbnail/page.tsx
'use client'

import { ImageUploader } from '@mediakit-dev/react'

export default function UploadThumbnail({
  params
}: {
  params: { courseId: string }
}) {
  async function handleUpload(asset: { id: number }) {
    // Save the image asset_id to your course
    await fetch(`/api/courses/${params.courseId}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ thumbnail_asset_id: asset.id }),
    })
  }

  return (
    <div className="max-w-md mx-auto py-12">
      <h1 className="text-2xl font-bold mb-6">Course Thumbnail</h1>
      <ImageUploader
        onUploadComplete={handleUpload}
        accept="image/jpeg,image/png,image/webp"
        maxSizeMB={10}
      />
    </div>
  )
}

4.2 Display Optimized Thumbnails

Use MediaImage for automatic optimization, or construct the URL directly:

components/CourseCard.tsx — Using React SDK
import { MediaImage } from '@mediakit-dev/react'

export function CourseCard({ course }: { course: Course }) {
  return (
    <div className="rounded-xl overflow-hidden border">
      {/* Automatically: WebP, lazy loaded, blur placeholder */}
      <MediaImage
        assetId={String(course.thumbnail_asset_id)}
        width={640}
        height={360}
        fit="cover"
        format="webp"
        quality={85}
        alt={course.title}
        className="w-full aspect-video object-cover"
      />
      {/* Generates: /api/img/{id}?w=640&h=360&fit=cover&format=webp&q=85 */}

      <div className="p-4">
        <h3 className="font-bold">{course.title}</h3>
        <p className="text-sm text-gray-500">{course.instructor}</p>
      </div>
    </div>
  )
}
components/CourseCard.tsx — Direct URL (no SDK)
const MEDIAKIT_URL = process.env.NEXT_PUBLIC_MEDIAKIT_URL

export function CourseCard({ course }: { course: Course }) {
  const thumbnailUrl = `${MEDIAKIT_URL}/api/img/${course.thumbnail_asset_id}`

  return (
    <div className="rounded-xl overflow-hidden border">
      <img
        src={`${thumbnailUrl}?w=640&h=360&fit=cover&format=webp&q=85`}
        srcSet={`
          ${thumbnailUrl}?w=320&h=180&format=webp 320w,
          ${thumbnailUrl}?w=640&h=360&format=webp 640w,
          ${thumbnailUrl}?w=960&h=540&format=webp 960w
        `}
        sizes="(max-width: 640px) 320px, (max-width: 1024px) 640px, 960px"
        alt={course.title}
        loading="lazy"
        className="w-full aspect-video object-cover"
      />
      <div className="p-4">
        <h3 className="font-bold">{course.title}</h3>
      </div>
    </div>
  )
}

4.3 Instructor Avatars

tsx
<MediaImage
  assetId={String(instructor.avatar_asset_id)}
  width={96}
  height={96}
  fit="cover"
  format="webp"
  quality={90}
  alt={instructor.name}
  className="rounded-full"
/>
{/* → /api/img/55?w=96&h=96&fit=cover&format=webp&q=90 */}

4.4 OG Images for SEO

app/courses/[courseId]/page.tsx
export async function generateMetadata({ params }) {
  const course = await getCourse(params.courseId)
  const MEDIAKIT_URL = process.env.NEXT_PUBLIC_MEDIAKIT_URL

  return {
    title: course.title,
    openGraph: {
      images: [
        {
          // 1200×630 is the standard OG image size
          url: `${MEDIAKIT_URL}/api/img/${course.thumbnail_asset_id}?w=1200&h=630&fit=cover&format=jpeg&q=90`,
          width: 1200,
          height: 630,
        }
      ],
    },
  }
}

Part 5 — Database Schema

Your courses app only stores the MediaKit asset IDs — not the files themselves:

Your courses database
CREATE TABLE courses (
  id            SERIAL PRIMARY KEY,
  title         TEXT NOT NULL,
  description   TEXT,
  instructor_id INTEGER REFERENCES users(id),
  thumbnail_asset_id INTEGER,  -- MediaKit image asset ID
  price         DECIMAL(10,2) DEFAULT 0,
  is_published  BOOLEAN DEFAULT false,
  created_at    TIMESTAMP DEFAULT NOW()
);

CREATE TABLE lessons (
  id              SERIAL PRIMARY KEY,
  course_id       INTEGER REFERENCES courses(id),
  title           TEXT NOT NULL,
  sort_order      INTEGER DEFAULT 0,
  video_asset_id  INTEGER,  -- MediaKit video asset ID
  is_free         BOOLEAN DEFAULT false,
  duration        REAL,     -- cached from MediaKit
  created_at      TIMESTAMP DEFAULT NOW()
);
Go models
type Course struct {
    ID                uint    `json:"id" gorm:"primaryKey"`
    Title             string  `json:"title"`
    Description       string  `json:"description"`
    InstructorID      uint    `json:"instructor_id"`
    ThumbnailAssetID  *uint   `json:"thumbnail_asset_id"`  // MediaKit
    Price             float64 `json:"price"`
    IsPublished       bool    `json:"is_published"`
}

type Lesson struct {
    ID            uint   `json:"id" gorm:"primaryKey"`
    CourseID      uint   `json:"course_id"`
    Title         string `json:"title"`
    SortOrder     int    `json:"sort_order"`
    VideoAssetID  *uint  `json:"video_asset_id"`  // MediaKit
    IsFree        bool   `json:"is_free"`
    Duration      float64`json:"duration"`
}

Part 6 — CDN & Performance

6.1 Cloudflare Caching Rules

Set up these cache rules in Cloudflare for your MediaKit API domain:

Cloudflare → Caching → Cache Rules
Rule 1: Cache Image Transforms
  When: URI Path contains "/api/img/"
  Then: Cache Everything
    Edge TTL: 30 days
    Browser TTL: 7 days

Rule 2: Cache HLS Segments
  When: URI Path ends with ".ts" OR URI Path ends with ".m3u8"
  Then: Cache Everything
    Edge TTL: 1 year (segments are immutable)
    Browser TTL: 1 day

Rule 3: Cache Thumbnails/Sprites
  When: URI Path contains "/sprites/" OR URI Path contains "/thumbnail"
  Then: Cache Everything
    Edge TTL: 30 days
    Browser TTL: 7 days

6.2 Image Performance Tips

Use CaseURL Parameters~Size
Course card (mobile)?w=320&h=180&format=webp&q=80~15 KB
Course card (desktop)?w=640&h=360&format=webp&q=85~40 KB
Hero banner?w=1200&h=400&fit=cover&format=webp&q=90~80 KB
Avatar?w=96&h=96&fit=cover&format=webp&q=90~3 KB
Blur placeholder?w=20&blur=10&q=30~0.3 KB
OG image (SEO)?w=1200&h=630&fit=cover&format=jpeg&q=90~100 KB

6.3 Video Performance

MediaKit's HLS output is already CDN-optimized:

  • Adaptive bitrate — Player auto-switches between 360p/480p/720p/1080p based on connection speed
  • Small segments — 6-second HLS chunks start playing instantly
  • Cloudflare caches — After first viewer, segments are served from edge (0 origin load)
  • Sprites — Hover preview uses a single sprite sheet instead of hundreds of thumbnail requests

Part 7 — AI Chapter Markers

Auto-generate chapters for long course videos:

After video is transcoded
// Trigger chapter generation via your backend
async function generateChapters(videoAssetId: number) {
  const res = await fetch(
    `${MEDIAKIT_URL}/api/ai/videos/${videoAssetId}/analyse`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': API_KEY,
      },
      body: JSON.stringify({ type: 'chapters' }),
    }
  )
  return res.json()
}

// Chapters appear in the playback response:
// GET /api/mediakit/videos/1/playback
// {
//   "chapters": [
//     { "title": "Introduction", "start_time": 0 },
//     { "title": "Setting up the project", "start_time": 45 },
//     { "title": "Building the API", "start_time": 180 },
//     { "title": "Testing", "start_time": 420 },
//     { "title": "Deployment", "start_time": 600 }
//   ]
// }

Part 8 — Webhook Integration

Get notified when videos finish transcoding to update your lessons automatically:

app/api/webhooks/mediakit/route.ts
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'

const WEBHOOK_SECRET = process.env.MEDIAKIT_SIGNED_URL_SECRET!

export async function POST(req: NextRequest) {
  const body = await req.text()
  const signature = req.headers.get('x-mediakit-signature') || ''

  // Verify HMAC signature
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(body)
    .digest('hex')

  if (signature !== expected) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
  }

  const event = JSON.parse(body)

  switch (event.event) {
    case 'video.transcoded':
      // Update lesson duration from MediaKit
      const assetId = event.data.asset_id
      const duration = event.data.duration

      await db.lesson.updateMany({
        where: { video_asset_id: assetId },
        data: {
          duration: duration,
          // Mark as ready for students
        },
      })
      break

    case 'asset.failed':
      // Notify instructor that upload failed
      console.error('Asset failed:', event.data)
      break
  }

  return NextResponse.json({ received: true })
}

Summary — Complete Architecture

text
Your Courses App                          MediaKit
┌───────────────────┐                    ┌──────────────────┐
│                   │                    │                  │
│  Next.js Frontend │                    │  Go API          │
│  ┌─────────────┐  │   upload presign   │  ┌────────────┐  │
│  │ VideoPlayer │──┼───────────────────▶│  │ R2 / S3    │  │
│  │ MediaImage  │  │   HLS playback     │  │ FFmpeg     │  │
│  │ ImageUpload │  │◀──────────────────┼──│ Image Proxy│  │
│  └─────────────┘  │                    │  └────────────┘  │
│                   │                    │                  │
│  Go Backend       │   X-API-Key auth   │  Redis + PG      │
│  ┌─────────────┐  │──────────────────▶│  Analytics       │
│  │ /presign    │  │   webhook events   │  Webhooks        │
│  │ /complete   │◀─┼───────────────────│  AI Analysis     │
│  │ /signed-url │  │                    │                  │
│  └─────────────┘  │                    │                  │
│                   │                    │                  │
│  Your Database    │                    │  MediaKit DB     │
│  ┌─────────────┐  │                    │  ┌────────────┐  │
│  │ courses     │  │   stores only      │  │ assets     │  │
│  │ lessons     │──┼── asset_id refs ──▶│  │ videos     │  │
│  │ users       │  │                    │  │ qualities  │  │
│  └─────────────┘  │                    │  └────────────┘  │
└───────────────────┘                    └──────────────────┘

Key Points

  • - Your app stores only asset_id references — not files
  • - Files go directly browser → R2/S3 (presigned URL) — zero load on your API
  • - Video transcoding happens in MediaKit's background workers
  • - Images are transformed on-demand and cached at CDN edge
  • - Paid content uses signed URLs (time-limited, IP-locked)
  • - Webhooks notify your app when processing completes