Showing any file in your application, step by step

This page takes you from a file in a bucket, or a file you searched, to that file shown inside your own page: a video or audio file that plays, an image, a PDF, a Word document, a spreadsheet, a CSV or a text file that opens in place, and a download card for anything else. It covers the routes your backend needs, the component on your page, the settings that let large files open at once, the page policy your site may need, what each file type shows, how to check it works and what the errors mean. The component's inputs are in Angular and React; the storage rules are in Storage and the search rules in Integration.

You need: a bucket with files in it (or a library of searched files), an admin API key for your server (Developers → API keys), your plycdn organization id, and a backend running one of plycdn's server packages (ASP.NET Core, Node.js or Python). For the component on your page, Angular or React.

1How it works

  1. Your page shows the viewer and tells it which file. The file is named by the bucket and key (or id) the file browser gave you, or by the search result.
  2. The viewer asks your backend for a short-lived link. Your backend decides who may see the file and answers with a link; the browser never holds your key.
  3. The file is read straight from plycdn (or from your own storage, for content search) into the page. Nothing is converted, uploaded or sent anywhere else. A large file is read a part at a time, only as far as the person scrolls.
  4. Anything the viewer cannot show ends on a card with the file's name, type and size and a working Download.

The viewer never draws a file's own markup. HTML, XML, code and Markdown are shown as text, line by line, never rendered.

2Where previews are in the dashboard

  • Storage → Storage buckets → (a bucket) → Files → Preview. The eye icon on a row, or Preview in the file's details, opens the file in a side panel. Open in new tab stays in the panel's header. The panel shows a How it works link to this page.
  • Content search → Library → (a file) → Preview. The file's Preview tab shows the file; a recording plays there and a document opens in place.
  • Content search → Search → a document result → Open at the match. The panel opens on the page, row or line that matched, with the words marked.

Documents are read by the page from the bucket's address, so the bucket must allow the dashboard's origin. A document in a bucket that doesn't ends on the card with This bucket doesn't let other websites read its files, so this document can't open here. Add https://plycdn.com to Allowed origins on the bucket's Delivery tab. and a link, Open the bucket's Delivery tab. Images, video, audio and PDFs show either way. A private bucket whose links work only from the address they are signed for can't be previewed in the dashboard: the Library preview and Open at the match say Links on this bucket work only from the address they are signed for, so preview them from your application.

3Show a file from a bucket in your application

This is how to show a PDF, a Word or Excel document, an image or a video in your app, straight from a bucket.

Add the routes your backend needs

The viewer calls your backend, never plycdn. It asks to list files (the file browser), to read one file's details (GetObject) and to sign a link (Sign). Everything is refused until your rule says yes.

ASP.NET Core

builder.Services.AddPlycdnStorage(builder.Configuration);
builder.Services.AddScoped<IPlycdnStorageAuthorizer, ContractFilesAuthorizer>();
// ...
app.UseAuthentication(); app.UseAuthorization(); app.MapControllers();
public sealed class ContractFilesAuthorizer : IPlycdnStorageAuthorizer
{
    public Task<bool> AuthorizeAsync(ClaimsPrincipal user, PlycdnOperation operation, Guid bucketId,
                                     string? key, CancellationToken ct) =>
        Task.FromResult(operation switch
        {
            PlycdnOperation.ListObjects or PlycdnOperation.GetObject or PlycdnOperation.Sign => user.IsInRole("staff"),
            _ => false,
        });
}

Node.js

app.set('trust proxy', 1);
app.use('/api/plycdn/v1', createPlycdnRouter({
  storage,
  signer: new PlycdnLinkSigner(storage),
  resolveOrganization: (req) => req.user?.plycdnOrganizationId ?? null,
  authorize: (req, operation, bucketId, key) =>
    (operation === PlycdnOperation.ListObjects || operation === PlycdnOperation.GetObject || operation === PlycdnOperation.Sign)
      && req.user?.roles.includes('staff') === true,
}));
app.use(express.json());

Python

def authorize(request, operation, bucket_id, key):
    allowed = (PlycdnOperation.ListObjects, PlycdnOperation.GetObject, PlycdnOperation.Sign)
    return operation in allowed and "staff" in request.state.user.roles

app.include_router(plycdn_router(
    storage=storage, signer=AsyncPlycdnLinkSigner(storage),
    resolve_organization=resolve_organization, authorize=authorize,
), prefix="/api/plycdn/v1")

The organization always comes from the signed-in user, never from the browser. The Node.js and Python rules must return exactly true / True. Mount the Node.js router before express.json(). A refused person gets 403 forbidden, and the viewer shows the card's sentence, not the file.

Show a PDF, Word, Excel or image file from a bucket in your app

Put the viewer on your page, bound to the file browser's open event. In Angular, import PlycdnStorageModule.forRoot() once:

Angular

import { PlycdnStorageModule } from '@plycdn/angular/storage';
import { PlycdnVideoModule } from '@plycdn/angular/video';   // only if the bucket's files include library videos

@NgModule({
  imports: [
    PlycdnStorageModule.forRoot({ apiBaseUrl: '/api/plycdn/v1' }),
    PlycdnVideoModule.forRoot({ apiBaseUrl: '/api/plycdn/v1' }),
  ],
})
export class AppModule {}
<plycdn-file-browser [bucketId]="bucketId" (open)="opened = $event"></plycdn-file-browser>
<plycdn-file-viewer *ngIf="opened" [object]="opened" (stateChange)="state = $event"></plycdn-file-viewer>

Import PlycdnVideoModule.forRoot() when <plycdn-file-viewer> is given a library video ([libraryId] and [videoId]). Without it that video shows a playback_link_failed card instead of playing.

React

import { PlycdnProvider, PlycdnFileBrowser, PlycdnFileViewer, type PlycdnObject } from '@plycdn/react/storage';

function Files({ bucketId }: { bucketId: string }) {
  const [opened, setOpened] = useState<PlycdnObject | null>(null);
  return (
    <PlycdnProvider config={{ baseUrl: '/api/plycdn/v1' }}>
      <PlycdnFileBrowser bucketId={bucketId} onOpen={setOpened} />
      {opened && <PlycdnFileViewer key={opened.id} object={opened} onStateChange={(change) => console.log(change.mode)} />}
    </PlycdnProvider>
  );
}

Give the viewer a key when you switch between files of your own (a source with getLink) that share a name, type and size, so each one opens afresh. For a stored file the id already tells them apart.

A file can also be named without the browser: [bucketId] with [objectKey] (Angular) or bucketId with objectKey (React), or by objectId. Adding contentType and size saves one read. ttlSeconds sets how long each link lives and hostname signs on one of the bucket's custom domains. When a link expires or is refused the viewer asks your route for a new one, up to three times, and carries on where it was.

For files that are not in a plycdn bucket, hand the viewer a source: the file's name, what you know about it, whose host the link is on (delivery: 'customer' for your own storage, 'plycdn' for a plycdn link), and a getLink function that returns { url }. It is asked again when the link expires:

const source = { name: 'handbook.pdf', contentType: 'application/pdf', delivery: 'customer',
                 getLink: () => fetch('/api/handbook-link').then((r) => r.json()) };   // { url, expiresAt? }

The request the viewer makes

The browser never calls plycdn for a link: the viewer posts to your backend's sign route, and your backend (with its key) answers. This is the request, as your server package's route receives it:

POST {apiBaseUrl}/sign
Content-Type: application/json

{ "bucketId": "…", "key": "contracts/2026/lease.pdf" }
{ "url": "https://contracts.plycdn.net/contracts/2026/lease.pdf?token=…&expires=1790000000",
  "expiresAt": "2026-10-31T12:15:00+00:00" }

ttlSeconds and hostname are added when you set them. When a link was refused the viewer also sends "refused": true to a route that says it takes it (plycdn's server packages do), so the route signs a fresh one. Your route then makes the same call to plycdn with your key (POST /api/content/v1/sign, Storage), or signs locally with the server package's signer.

4Show a search result's file at the match

Content search shows an indexed file at the place that matched: the page of a PDF, the row of a spreadsheet, the line of a text file, the paragraph of a Word document, the moment of a recording. The packages read the file through the source-access callback your server already has (the contract): the viewer asks for a fresh read link whenever it needs one. Nothing new is added on your server; this is the callback your storage implements for indexing. For a file in your own storage it returns a read link:

ASP.NET Core

public async Task<SourceAccess> ResolveReadAsync(Guid organizationId, string externalId, string sourceVersion, CancellationToken ct)
{
    var blob = container.GetBlobClient(externalId);
    var properties = (await blob.GetPropertiesAsync(cancellationToken: ct)).Value;
    if (properties.ETag.ToString() != sourceVersion) throw new UnauthorizedAccessException("The file changed");
    var expiry = DateTimeOffset.UtcNow.AddHours(2);
    return new SourceAccess(Sign(blob, BlobSasPermissions.Read, expiry), expiry);
}

Node.js

const storage: CustomerFileStorage = {
  createUpload, completeUpload,                                    // your uploads, if browsers upload
  async resolveRead(organizationId, externalId, sourceVersion) {
    await refuseUnlessCurrent(organizationId, externalId, sourceVersion);   // your own rule
    const expiresAt = new Date(Date.now() + 2 * 3600_000);
    return { url: await signedReadLink(externalId, expiresAt), expiresAt: expiresAt.toISOString() };
  },
};

Python

class CourseStorage:
    def resolve_read(self, organization_id: str, external_id: str, source_version: str) -> dict:
        refuse_unless_current(organization_id, external_id, source_version)   # your own rule
        expires = datetime.now(timezone.utc) + timedelta(hours=2)
        return {"url": signed_read_link(external_id, expires), "expiresAt": expires.isoformat()}

Then show the result. In Angular, import PlycdnContentSearchModule.forRoot() once, as for search:

Angular

<plycdn-content-results [results]="page?.items || []" (resultSelected)="picked = $event"></plycdn-content-results>
<plycdn-content-file-viewer *ngIf="picked" [result]="picked" [terms]="page?.terms || []"
                            (unavailable)="offerDownload($event)"></plycdn-content-file-viewer>

React

import { PlycdnContentFileViewer, useSearch } from '@plycdn/content-search-react';

function Results() {
  const { results, search } = useSearch();
  const [picked, setPicked] = useState<SearchResult | null>(null);
  // ... a search box that calls search(), a list of results.items that calls setPicked
  return picked && (
    <PlycdnContentFileViewer key={picked.assetId} result={picked} terms={results?.terms ?? []}
                             onUnavailable={(code) => offerDownload(code)} />
  );
}

A file by its id works too: [assetId] or assetId. [location], [terms] and [snippet] move and mark the match, and a new result for the same file moves within it without reloading. (unavailable) and onUnavailable give the error code when the file ends on a card for a reason other than "this type isn't shown", so you can offer the file another way.

<plycdn-content-document> and <plycdn-content-playback> run on the same viewer, with the same inputs and outputs.

5Play a library video or an audio file

Nothing to configure. Give the viewer a library video (libraryId with videoId) and it plays in the plycdn player, exactly as in Playing video, with quality, speed, captions, resuming, serial playback and the watermark and logo. In Angular that needs PlycdnVideoModule.forRoot() imported, as above. An audio file, or a video file that sits in an ordinary bucket, plays in the same controls. A file whose container this browser cannot play (some .avi and .mkv files) ends on the card with This browser can't show this file.

In content search, a library video plays as a plain stream: there is no serial playback, watermark or logo in <PlycdnContentFileViewer> (React). Use the storage and video player for videos that need them.

6Let documents open in place

Images, video and audio play from any link. A PDF, a Word document, a spreadsheet, a CSV or a text file is read by your page's own script, so the browser lets it through only when the file's host allows your page's origin.

For a plycdn bucket, list your page's origins on the bucket: Delivery → Caching & CORS → Configure (the bucket's Delivery tab, Allowed origins), or cors from your server package or the API. Exact origins, https://, plus http://localhost:4200 while you build. Nothing else is needed on a plycdn bucket: reading part of a file is always allowed (below).

API

PATCH /api/content/v1/buckets/{bucketId}
Authorization: ApiKey <admin key>

{ "cors": { "allowedOrigins": ["https://learn.example.com", "http://localhost:4200"] } }

ASP.NET Core

await plycdn.UpdateBucketAsync(organizationId, bucketId, new BucketPatch
{
    Cors = new BucketCors { AllowedOrigins = new[] { "https://learn.example.com", "http://localhost:4200" } },
});

Node.js

await storage.updateBucket(organizationId, bucketId, {
  cors: { allowedOrigins: ['https://learn.example.com', 'http://localhost:4200'] },
});

Python

storage.update_bucket(organization_id, bucket_id, {
    "cors": {"allowedOrigins": ["https://learn.example.com", "http://localhost:4200"]},
})

That is all. On a plycdn bucket, reading part of a file is always allowed: the Range request header is allowed, and Content-Length, Content-Range and Accept-Ranges are exposed, whatever else you set. A large file therefore opens at once: a spreadsheet shows its first rows, and a PDF its first page, without the whole file being downloaded, and the viewer fetches more as the person scrolls. A bucket with no allowed origins has no CORS at all, and its documents show the card.

For your own storage (content search with files you host), set the same on the storage account or container: allow your page's origin for GET and HEAD, allow the request header Range, and expose Content-Length, Content-Range and Accept-Ranges. Without Range allowed the viewer reads the whole file instead, and without Content-Range exposed it reads a file whole unless its size is known. Both still work, only slower, and only up to the whole-file limits below.

Save PDFs "optimised for the web" (also called fast web view or linearised). The first page is then at the start of the file and opens at once. Other PDFs open too, with one more read for the page index at the end. A PDF built with a flat page index is read almost whole when it opens: the page-by-page loading only helps PDFs saved optimised for the web.

7Content-Security-Policy

If your site sends a Content-Security-Policy, allow the file host (for plycdn buckets, https://*.plycdn.net or your custom domain):

Directive Needs the file host when you show If missing
img-src images; plus blob: for the pictures inside Word documents the card; Word pictures keep their placeholder (Picture not shown here)
media-src (plus blob: if you keep upload previews) video, audio the player reports it can't load; the card
connect-src PDF (marked), Word, Excel, CSV, text, Markdown, HTML and XML as text; the video streams a PDF uses the browser's own viewer if frame-src allows it, or the card; the others show the card
frame-src a PDF that can't be drawn in place, shown in the browser's own viewer the card

Nothing needs worker-src for documents, 'unsafe-eval' or a change to script-src. The React viewer adds one <style> element: pass nonce when your style-src has no 'unsafe-inline', as for the other components.

8What each file type shows

Type What the person sees
Video, audio A player with the usual controls
Images: PNG, JPEG, GIF, WebP, AVIF, BMP, ICO, SVG The picture, to fit or at 100%, with zoom. SVG is shown as a picture only. HEIC and TIFF show only in Safari; elsewhere they end on the card
PDF Pages one after another with zoom, fit to width and a page count, opened at the match when there is one
Word (.docx) Headings, paragraphs, lists and tables in order, with pictures as they scroll into view (PNG, JPEG, GIF, WebP and BMP; a picture saved only as SVG keeps its placeholder). Headers, footnotes and charts are not shown; the viewer says so
Excel (.xlsx) One table per sheet, with the file's own row numbers. The sheets are tabs; a search result opens the sheet that matched
CSV, TSV A table, honouring quoted fields
Text, Markdown, JSON, log, YAML, subtitles, HTML and XML, code Numbered lines of the file as written. HTML and XML are shown as text, never rendered
PowerPoint, ZIP and other archives, anything else A download card: the name, the type, the size and Download

Limits. The viewer loads at most this much into the page for one file: 10 MB of text (200,000 lines), 20 MB of CSV (200,000 rows and 200 columns), and 1,000,000 cells in one view (every sheet kept open counts). Past a limit the view ends with The rest of this file isn't shown here. and Download, not an error. A 2 GB log or a 300 MB spreadsheet still opens. When the file's host can't answer partial reads, whole-file limits apply instead: text 10 MB, CSV 20 MB, Excel 25 MB and Word 50 MB, and a file over its limit gets the card with Too large to preview here. A text or CSV file stopped at the limit also says how much is shown: Showing the first … of …. Content search never shows less than it did: it raises the document limits to the largest document your library indexes.

9Check it works

Put five files in a bucket and open each in your page (or in Storage → Storage buckets → (bucket) → Files → Preview):

  1. A PDF. Pages appear one after another; zoom and the page count work. Scroll to the end of a long one: pages arrive as you go.
  2. An Excel file with several sheets. The first sheet shows at once with row numbers; choosing another tab shows that sheet.
  3. An image. It appears to fit; zoom works.
  4. A video. It plays with the player's controls.
  5. A PowerPoint file. A card shows its name, Presentation, its size and Download, and Download saves the file.

In your browser's developer tools, the PDF and the spreadsheet show requests to your bucket with status 206 and short bodies: parts of the file, not the whole. If you see one request with status 200 and the whole size, the file's host ignored the part asked for (for your own storage, allow the Range header: above); the file still opens, read whole. If the request has no readable answer at all (the console reports it was blocked), your page's origin isn't allowed.

10Errors and fixes

Every failure ends on the card with a working download. For a card that is not just "type not shown", the code reaches your page through (failed) / onError (stored files) or (unavailable) / onUnavailable (content search). (stateChange) and onStateChange say what is on screen (mode, renderer, card), without the code.

Code What the person sees When, and the fix
preview_too_large Too large to preview here. The file is over a whole-file limit because the file's host can't answer partial reads. Allow your origin and Range on the bucket or storage (above), then it opens in parts. Download stays
preview_unreachable Couldn't be opened here. Try again A network error, a redirect on a plycdn link, a 5xx, or a document that your page isn't allowed to read. Check Delivery → Caching & CORS lists your page's exact origin; check the Content-Security-Policy connect-src; then Try again. The card is retryable
preview_unreadable This file couldn't be read. Download it to open it. The file is damaged, encrypted, not what its name says (a text file that is binary, a Word file that isn't a ZIP), or changed while it was open. Download it and open it on its own; not retryable
preview_unsupported_here This browser can't show this file. The browser can't play or draw it: HEIC or TIFF outside Safari, a video container the browser can't play, or an older browser for Word and Excel (browsers). Download stays
source_unavailable This file isn't available right now. Content search: the file was removed or changed since it was indexed, or the component has no search client. Re-index it or offer the original
playback_link_failed The player's own sentence A library video was given to <plycdn-file-viewer> without PlycdnVideoModule.forRoot() (Angular), or three link renewals failed. Import the module; or Try again
video_not_found The player's own sentence The videoId isn't in that library, or isn't yours
link_expired The player's own sentence A link ran out and could not be renewed. Check your sign route answers for this person
forbidden You do not have permission to do this. (your sign route's own sentence, or yours from messages) Your authorizer refused Sign, GetObject or ListObjects. Allow the operation for this person

A document that shows the card even though the bucket is set up is almost always one of two things: the page's origin isn't in the bucket's allowed origins (spelling, http against https, a missing port), or the Content-Security-Policy connect-src doesn't include the file host. The developer tools' console names which.