Preview anything, anywhere — without a single line of server-side conversion code.
If you've ever built a web app that handles file attachments — be it an HR portal displaying resumes, an engineering archive showing CAD drawings, or an internal knowledge base rendering Markdown alongside Excel reports — you already know the pain. Every format demands its own rendering pipeline. PDFs need PDF.js. Word documents need a converter. CAD files? Good luck. And don't even get me started on OFD, Typst, or compressed email archives.
Enter Flyfish File Viewer: a browser-native, pure frontend file preview component that eats all of this complexity for breakfast — 206+ file extensions across 24 preview pipelines, zero server-side conversion, and native support for Vue, React, Svelte, jQuery, and vanilla Web Components.
In this post, I'll walk you through what makes Flyfish tick, how to wire it up with a Go backend that serves files, and a React frontend that renders them — all without a single file ever leaving your infrastructure.
Flyfish File Viewer is an open-source project by flyfish-dev built for one job: preview business attachments directly in the browser. It's designed for internal tools, OA systems, knowledge bases, approval workflows, attachment centers, and private deployments — the kind of software where files come in every shape and size and you can't afford to push them through a third-party conversion API.

Here's the headline feature list:
| Category | Supported Formats |
|---|---|
| Office | DOCX, XLSX, PPTX, PDF, OFD, Typst, RTF, ODT |
| Engineering | DWG, DXF, DWF, GLB/GLTF, STL, STEP, IFC, 3DM, GeoJSON |
| Archives | ZIP, 7Z, RAR, TAR.GZ, ISO, APK — with nested preview |
| EML, MSG, MBOX | |
| Diagrams | Mermaid, PlantUML, draw.io, Excalidraw, XMind |
| Code & Data | Markdown, 50+ code languages, SQLite, Parquet, Avro, WASM |
| Media & Design | PSD, AI, EPS, HEIC, all common image/audio/video formats |
The killer feature: every single one renders purely in the browser. No LibreOffice headless server. No queue of conversion jobs. No temporary PDF files cluttering your /tmp. Just load the file URL, and the right renderer kicks in automatically.
Flyfish's architecture is beautifully modular. At its core is @file-viewer/core — a framework-agnostic engine that handles format detection, resource loading, renderer protocol, lifecycle management, and unified operations (search, zoom, print, export, watermark).
On top of that sits a layered ecosystem:
@file-viewer/renderer-*): Individual rendering engines for PDF, Word, Excel, PowerPoint, CAD, Typst, Archives, Drawings, and more. Each renderer is loaded lazily — only when the specific file type is encountered.@file-viewer/preset-*): Convenience bundles. preset-lite covers images and code. preset-office covers the Office suite. preset-engineering covers CAD, 3D, and drawings. preset-all is the full kitchen sink.@file-viewer/react, @file-viewer/vue3, @file-viewer/web, etc.): Native components for each frontend ecosystem that all share the same core, options, events, and controller API.The heavy lifting — PDF parsing via pdfjs-dist, CAD rendering via LibreDWG WASM, Typst compiling via @myriaddreamin/typst.ts — all happens inside Web Workers and WASM modules that load on demand. Your main thread stays snappy.
Here's a key design decision Flyfish makes: for React, the recommended path is to host the Vue 3 preview baseline as a static asset and embed it via iframe. This isn't a compromise — it's a deliberate architectural choice. The Vue 3 component is the canonical preview engine; the React package wraps it with parameter passing, iframe management, and a binary push protocol.
Why? Because the file viewer is a complex, heavy application by nature. Isolating it in an iframe gives you sandboxing, cleaner CSS scoping, and independence from your React app's render cycles.
npm install @file-viewer/react @file-viewer/preset-office
// components/FilePreview.tsx
import FileViewer from '@file-viewer/react'
import officePreset from '@file-viewer/preset-office'
interface FilePreviewProps {
fileUrl: string
fileName?: string
}
export function FilePreview({ fileUrl, fileName }: FilePreviewProps) {
return (
<div style={{ height: '100vh', width: '100%' }}>
<FileViewer
url={fileUrl}
options={{
preset: officePreset,
rendererMode: 'replace',
theme: 'light',
toolbar: { position: 'bottom-right' },
watermark: fileName
? { text: `Internal — ${fileName}`, opacity: 0.12 }
: undefined,
}}
/>
</div>
)
}
// components/FilePreviewIframe.tsx
import { useMemo } from 'react'
interface FilePreviewIframeProps {
fileUrl: string
}
export function FilePreviewIframe({ fileUrl }: FilePreviewIframeProps) {
const previewUrl = useMemo(() => {
const base = '/file-viewer/index.html'
const params = new URLSearchParams({
url: fileUrl,
options: JSON.stringify({
toolbar: { position: 'bottom-right' },
theme: 'light',
}),
})
return `${base}?${params.toString()}`
}, [fileUrl])
return (
<iframe
src={previewUrl}
style={{ width: '100%', height: '100%', border: 0 }}
title="File Preview"
/>
)
}
For the iframe approach, you'll need to host the static preview assets. You can either:
dist/ folder from @file-viewer/web-full into your public/file-viewer/ directoryflyfishdev/file-viewer:latest as a sidecar serviceThe Go backend has two responsibilities: serve the actual files for preview, and (optionally) host the static previewer assets.
.
├── main.go
├── go.mod
├── frontend/ # React app (Vite)
│ └── ...
└── uploads/ # File storage directory
// main.go
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/google/uuid"
)
// FileMeta holds metadata about an uploaded or stored file.
type FileMeta struct {
ID string `json:"id"`
OriginalName string `json:"original_name"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
CreatedAt string `json:"created_at"`
URL string `json:"url"`
}
// In-memory file registry (use a database in production).
var fileRegistry = make(map[string]FileMeta)
func main() {
r := chi.NewRouter()
// Middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:5173", "http://localhost:3000"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
ExposedHeaders: []string{"Content-Disposition", "Content-Length", "Content-Range"},
AllowCredentials: true,
MaxAge: 300,
}))
// API routes
r.Route("/api", func(r chi.Router) {
r.Post("/files/upload", handleUpload)
r.Get("/files", handleListFiles)
r.Get("/files/{fileID}", handleGetFile)
})
// File serving with Range support (critical for PDF streaming)
r.Get("/files/{fileID}/raw", handleServeFile)
// Serve static previewer assets (production)
workDir, _ := os.Getwd()
previewerPath := filepath.Join(workDir, "public", "file-viewer")
if _, err := os.Stat(previewerPath); err == nil {
r.Handle("/file-viewer/*", http.StripPrefix("/file-viewer/",
http.FileServer(http.Dir(previewerPath))))
}
// Serve React frontend in production
r.Handle("/*", http.FileServer(http.Dir("./frontend/dist")))
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("Server starting on :%s", port)
if err := http.ListenAndServe(":"+port, r); err != nil {
log.Fatal(err)
}
}
// handleUpload accepts multipart file uploads.
func handleUpload(w http.ResponseWriter, r *http.Request) {
// Limit upload to 100MB
r.Body = http.MaxBytesReader(w, r.Body, 100<<20)
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, "File too large", http.StatusRequestEntityTooLarge)
return
}
file, header, err := r.FormFile("file")
if err != nil {
http.Error(w, "Invalid file upload", http.StatusBadRequest)
return
}
defer file.Close()
// Generate unique filename to prevent collisions
ext := filepath.Ext(header.Filename)
id := uuid.New().String()
storedName := id + ext
// Ensure uploads directory exists
uploadDir := "./uploads"
if err := os.MkdirAll(uploadDir, 0755); err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
destPath := filepath.Join(uploadDir, storedName)
dest, err := os.Create(destPath)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
defer dest.Close()
// Copy file contents
written, err := dest.ReadFrom(file)
if err != nil {
http.Error(w, "Server error", http.StatusInternalServerError)
return
}
// Detect MIME type from first 512 bytes
mimeType := header.Header.Get("Content-Type")
if mimeType == "" {
mimeType = detectMimeType(destPath)
}
meta := FileMeta{
ID: id,
OriginalName: header.Filename,
MimeType: mimeType,
Size: written,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
URL: fmt.Sprintf("/files/%s/raw", storedName),
}
fileRegistry[id] = meta
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(meta)
}
// handleListFiles returns all registered files.
func handleListFiles(w http.ResponseWriter, r *http.Request) {
files := make([]FileMeta, 0, len(fileRegistry))
for _, meta := range fileRegistry {
files = append(files, meta)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(files)
}
// handleGetFile returns file metadata.
func handleGetFile(w http.ResponseWriter, r *http.Request) {
fileID := chi.URLParam(r, "fileID")
meta, ok := fileRegistry[fileID]
if !ok {
http.Error(w, "File not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(meta)
}
// handleServeFile serves the raw file with proper headers for Flyfish preview.
// CRITICAL: Must support Range requests for PDF streaming.
func handleServeFile(w http.ResponseWriter, r *http.Request) {
fileID := chi.URLParam(r, "fileID")
// Look up the file
meta, ok := fileRegistry[fileID]
var filePath string
if ok {
// Find by metadata ID
for _, f := range fileRegistry {
if f.ID == fileID {
ext := filepath.Ext(meta.OriginalName)
filePath = filepath.Join("./uploads", fileID+ext)
break
}
}
}
if filePath == "" {
// Try direct path
filePath = filepath.Join("./uploads", fileID)
}
if _, err := os.Stat(filePath); os.IsNotExist(err) {
http.Error(w, "File not found", http.StatusNotFound)
return
}
// Detect MIME for content-type header
mimeType := detectMimeType(filePath)
// Set headers for Flyfish compatibility
w.Header().Set("Content-Type", mimeType)
w.Header().Set("Accept-Ranges", "bytes")
w.Header().Set("Access-Control-Allow-Origin", "*")
// For inline display in previewer
originalName := fileID
if ok {
originalName = meta.OriginalName
}
w.Header().Set("Content-Disposition",
fmt.Sprintf(`inline; filename="%s"`, originalName))
// http.ServeFile handles Range requests automatically
http.ServeFile(w, r, filePath)
}
// detectMimeType returns a MIME type based on file extension.
func detectMimeType(path string) string {
ext := strings.ToLower(filepath.Ext(path))
mimeMap := map[string]string{
".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".dwg": "application/acad",
".dxf": "application/dxf",
".zip": "application/zip",
".eml": "message/rfc822",
".md": "text/markdown",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".svg": "image/svg+xml",
".mp4": "video/mp4",
".mp3": "audio/mpeg",
".json": "application/json",
".txt": "text/plain",
".html": "text/html",
}
if mime, ok := mimeMap[ext]; ok {
return mime
}
return "application/octet-stream"
}
Range Requests Are Critical: PDF.js — the engine Flyfish uses internally for PDFs — needs HTTP Range support for progressive loading. http.ServeFile handles this automatically, so you're covered.
CORS Headers: If your React dev server runs on a different port, make sure your Go server sends proper CORS headers. The chi CORS middleware above handles that.
Content-Disposition: Use inline (not attachment) so the browser tries to display rather than download. This is crucial for Flyfish to intercept the response.
Static Asset Hosting: The previewer assets (/file-viewer/*) are served from a public/file-viewer/ directory using http.FileServer. For production, copy these from the npm package's dist folder or use the Docker image.

Here's the complete architecture at a glance:
┌─────────────────────────────────────────────────────────┐
│ Go Backend (:8080) │
│ ┌──────────┐ ┌─────────────┐ ┌───────────────────┐ │
│ │ /api/* │ │ /files/*/raw│ │ /file-viewer/* │ │
│ │ REST API │ │ File Server │ │ Static Assets │ │
│ └──────────┘ └─────────────┘ └───────────────────┘ │
└──────────────────────┬──────────────────────────────────┘
│ HTTP (Range support, CORS)
┌──────────────────────▼──────────────────────────────────┐
│ React Frontend │
│ ┌────────────────────────────────────────────────────┐ │
│ │ <FilePreviewIframe fileUrl="/files/report.docx"> │ │
│ │ ┌──────────────────────────────────────────┐ │ │
│ │ │ Flyfish Viewer (iframe) │ │ │
│ │ │ → Auto-detects .docx │ │ │
│ │ │ → Loads @file-viewer/renderer-word │ │ │
│ │ │ → Renders in Worker thread │ │ │
│ │ └──────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
node_modules/@file-viewer/web-full/dist/ → public/file-viewer//file-viewer/ is served as static files alongside your APIiframe.src to /file-viewer/index.html?url=/files/your-file.pdfcurl -I -H "Range: bytes=0-1023" http://localhost:8080/files/sample.pdf/raw — you should get a 206 Partial Content responseThe old way of handling file previews was expensive and fragile:
Flyfish eliminates all of it. Everything happens in the user's browser, on demand. For an internal tool serving a few hundred users, that's a massive reduction in operational complexity.
For enterprise deployments with strict data residency requirements, this is even more critical — your files never touch a third-party server. They stay on your Go backend, streamed directly to the browser, rendered locally.
flyfishdev/file-viewer:latest, a ready-to-run container with all static assets pre-built. Perfect as a sidecar in a Kubernetes pod alongside your Go service.Content-Security-Policy headers to restrict what the iframe can access.libarchive.js WASM module has its own memory ceiling for archive extraction.