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
cd your-nextjs-app
npm install @mediakit-dev/react1.2 Get an API Key
In your MediaKit admin panel (mediakitadmin.gritcms.com):
- Go to API Keys in the sidebar
- Click Create
- Name:
Courses App - Scopes:
read:assets,write:assets,read:analytics - Copy the key — it starts with
mk_live_
1.3 Environment Variables
# 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_secret1.4 Set Up the Provider
Wrap your app (or just the courses section) with MediaKitProvider:
'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>
)
}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:
'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):
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)
}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:
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)
}// 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:
'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:
'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":
'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:
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>
)
}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
<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
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:
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()
);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:
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 days6.2 Image Performance Tips
| Use Case | URL 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:
// 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:
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
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_idreferences — 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