What happens after you click Publish?

Inside the CMS behind this website: draft saves, revision snapshots, private previews, publication checks, scheduled posts, and cache updates—with C# and TypeScript examples.

Illustration of an article editor and a published page, titled What happens after you click Publish?
From an editable draft to a published article in the .NET and Angular CMS.

The Publish button on this website is small. What happens behind it takes a little more work.

I wanted to be able to rewrite an article without changing the version people were already reading. I also wanted to preview a specific version, schedule it for later, and take it offline without leaving a cached copy on the public site.

Those requirements shaped the CMS behind this blog. It uses .NET, EF Core and SQL Server for the API, Angular for the admin, and Angular server-side rendering for the public website.

In the previous post, I wrote about getting into the admin area with a password and an authenticator code. This time, I am looking at what happens once I am inside it.

This is a closer look at that code. The snippets below are extracts from the project, formatted for reading. Where I leave out parts of a method, I say what is missing. They explain the approach; they are not a complete CMS implementation.

1. Saving and publishing are different operations

The first rule is simple: Save Draft must never mean Publish.

The article has a working copy that I can edit. It also has a PublishedRevisionId, which points to the version visitors are allowed to see. A revision is a saved snapshot of the article at a particular moment.

If Revision 1 is live and I change the title in the editor, visitors keep seeing Revision 1. The new title becomes public only when I explicitly publish a new revision.

A working draft creates Revision 2 while the public website still reads Revision 1 until publication.

The draft update handler reflects that separation. This is its main method:

public async ValueTask<Versioned<ArticleAdminDetail>> Handle(
    Command request,
    CancellationToken cancellationToken)
{
    await using var transaction =
        await database.Database.BeginTransactionAsync(cancellationToken);

    var root = await data.RootAsync(request.Id, cancellationToken);
    SettingsData.RequireMatch(request.IfMatch, root.RowVersion);

    await data.ApplyAsync(root, request.Input, cancellationToken);
    data.Touch(root, "BlogPost.Updated");

    await database.SaveChangesAsync(cancellationToken);
    var result = await data.DetailAsync(root, cancellationToken);

    await transaction.CommitAsync(cancellationToken);
    return result;
}

root is the article record. ApplyAsync updates its editable fields, while Touch records the change for the audit history.

Notice what is absent: this method does not move PublishedRevisionId or increment the public content version. A draft save changes the working copy, not what the public website reads.

2. An old editor tab must not overwrite a newer save

Even on a website with one owner, two browser tabs are enough to cause trouble.

Imagine opening an article in both tabs. You save a correction in the first, then save the older content from the second. Without a check, the second save can silently undo the first.

The API returns an ETag, a version marker based on the database row version. The admin sends that marker back in the If-Match header when saving or publishing.

Here is the helper used by those operations:

public static string ETag(byte[] version)
{
    return $"\"{Convert.ToBase64String(version)}\"";
}

public static void RequireMatch(string? supplied, byte[] version)
{
    if (string.IsNullOrWhiteSpace(supplied))
    {
        throw new SettingsFailure(
            428, "precondition_required", "If-Match is required.");
    }

    if (!string.Equals(supplied, ETag(version), StringComparison.Ordinal))
    {
        throw new SettingsFailure(
            412, "precondition_failed", "The resource has changed.");
    }
}

A missing marker gets 428. An outdated one gets 412. The editor can then ask me to reload instead of pretending the save worked.

This is the early check that gives the browser a useful response. The database row version also acts as an EF Core concurrency token, so a change that happens while the request is running can still be detected when it saves. Microsoft's concurrency documentation explains that last part in more detail.

3. Capture the version that will actually be published

Publishing a live article directly from editable fields makes later changes hard to reason about. I use a snapshot instead.

The snapshot contains the title, slug, Markdown, image references, SEO fields, visibility, and copies of the selected category and tag details. This reduced extract shows the main content fields:

var snapshot = new BlogSnapshot
{
    Title = input.Title,
    Slug = input.Slug,
    Excerpt = input.Excerpt,
    Markdown = input.Markdown,
    CoverMediaId = input.CoverMediaId,
    Seo = input.Seo,
    CanonicalUrl = input.CanonicalUrl,
    Visibility = input.Visibility,
    ReadingTimeMinutes = renderer.Inspect(input.Markdown).ReadingTimeMinutes
};

The full capture method also resolves the author profile, categories, tags and related article IDs, and checks the snapshot size. Those parts are omitted here.

The application then wraps the snapshot in a revision record:

var revision = new BlogRevision
{
    PostId = root.Id,
    RevisionNumber = checked(number + 1),
    Payload = snapshot,
    ChangeNote = note,
    CreatedById = actor.UserId,
    SanitizedHtml = html.Length <= 200000 ? html : "",
    RendererVersion = renderer.Version
};

database.BlogRevisions.Add(revision);

In this extract, number is the last revision number, and html is the output from the Markdown renderer. The full method also records the revision's category, tag, related-article and media references.

Once created, that revision is not edited in place. Restoring an older revision copies its content back into the working draft. It does not rewrite history or automatically republish it.

4. Preview reads a revision, not the moving draft

When I press Preview, I want to know which version I am looking at. If the editor keeps changing underneath it, the preview stops being useful.

The preview points to a specific revision. Its access grant expires and belongs to the administrator who requested it. The server checks all of that before rendering:

var hash = CreatePreview.Hash(request.Token);
var now = clock.GetUtcNow().UtcDateTime;

var grant = await database.PreviewGrants
    .AsNoTracking()
    .SingleOrDefaultAsync(value =>
        value.TokenHash == hash &&
        value.ActorId == actor.UserId &&
        value.RevokedAtUtc == null &&
        value.ExpiresAtUtc > now,
        cancellationToken)
    ?? throw new SettingsFailure(
        404, "not_found", "The preview does not exist or has expired.");

The creation path checks that the revision belongs to the requested article. It generates a random token, stores its hash, and gives the grant a 15-minute lifetime. A preview token on its own does not make the article public.

For blog previews, the server renders that revision's Markdown through the private media path. Public article requests use a separate path. The public website does not need access to my draft or my admin cookie.

5. An uploaded image is not necessarily ready to publish

An upload can finish before the server has created its image variants. It can also be ready technically but still lack approval or meaningful alternative text.

I allow those images to be attached to a draft. Publication is stricter.

Here is the main media check from the publication validator, with only the formatting expanded:

if (asset is null ||
    asset.DeletedAtUtc is not null ||
    !asset.PublicDeliveryApproved ||
    asset.ProcessingState != MediaProcessingState.Ready ||
    !reference.IsDecorative &&
        string.IsNullOrWhiteSpace(reference.Alt ?? asset.AltText))
{
    throw new SettingsFailure(
        422,
        "media_unavailable",
        "Referenced media must be ready, approved and have alternative text.");
}

if (await media.ReadAsync(
    reference.Id, reference.Alt, null, cancellationToken) is null)
{
    throw new SettingsFailure(
        422, "media_unavailable", "Processed media variants are unavailable.");
}

The asset is loaded under a database lock before this check. The final lookup also checks that usable processed variants are available.

Other publication checks cover public visibility, at least one visible category, an available published author profile, supported Markdown and valid internal article or project links.

Drafts can be unfinished. Published content should pass the checks required to display it.

6. Publish changes the live pointer inside a transaction

The publish handler can use a revision I selected, or create one from the saved working draft. It then validates that revision before changing the live pointer.

These are two excerpts from the same database transaction. The first selects and validates the revision:

var revision = request.RevisionId is { } revisionId
    ? await revisions.FindAsync(root.Id, revisionId, cancellationToken)
    : await revisions.CreateAsync(
        root,
        await revisions.CaptureAsync(
            await data.ReadAsync(root, cancellationToken),
            cancellationToken),
        request.ChangeNote,
        cancellationToken);

await revisions.ValidatePublicationAsync(
    revision.Payload, root, cancellationToken);

The handler also checks the request version, claims the public slug, and handles redirects when an existing public slug changes. I have left those branches out of this sample.

Later in the same handler, the publication state is updated:

var now = clock.GetUtcNow().UtcDateTime;

root.PublishedRevisionId = revision.Id;
root.PublicationState = PublicationState.Published;
root.FirstPublishedAtUtc ??= now;
root.LastPublishedAtUtc = now;

root.ScheduledRevisionId = null;
root.ScheduledAtUtc = null;
root.ScheduleErrorCode = null;
root.ScheduleFailedAtUtc = null;

data.Touch(root, "BlogPost.Published");
await data.BumpGenerationAsync(cancellationToken);
await database.SaveChangesAsync(cancellationToken);

var result = await data.DetailAsync(root, cancellationToken);
await transaction.CommitAsync(cancellationToken);
return result;

The omitted scheduling branch completes the scheduled work item when a worker is publishing, or cancels outstanding scheduled work when I publish manually.

The transaction is the important boundary. Saving part of the work is not the same as committing it. If validation or a later database operation fails, the transaction does not commit a half-published article. The old live revision and public content version stay in place.

7. Public reads follow the published revision

Separating drafts from published content only works if public queries respect that separation.

The public blog query joins the article to its published revision. This is the opening of its eligibility query:

FROM [content].[BlogPost] p
INNER JOIN [content].[BlogRevision] r
    ON r.[Id] = p.[PublishedRevisionId]
    AND r.[PostId] = p.[Id]
WHERE p.[PublicationState] = 2
    AND p.[FirstPublishedAtUtc] <= {0}
    AND JSON_VALUE(r.[Payload], '$.visibility') = 'Public'

In this project, 2 is the stored value for Published. {0} is a query parameter for the current UTC time, not text taken from a visitor. This is a query fragment, not standalone SQL; the full query adds author and category eligibility checks.

The title and body come from the revision payload. They do not come from the editable title and body on the working record. Public reads also use a database snapshot so the content and its version number are read consistently.

That is what lets me leave a live article alone while I work on its replacement.

8. A cache must know when content changes

A successful database update is not enough if the public website keeps serving older HTML.

I use a shared ContentGeneration number in SQL Server. A public content change increments it in the same transaction as the change itself.

Cache version comparison: database and HTML both at 41 give a cache hit; database 42 with HTML 41 triggers fresh rendering.

The increment is small, but it needs to be coordinated across requests:

var generation = await database.ContentGenerations
    .FromSqlRaw(
        "SELECT * FROM [operations].[ContentGeneration] " +
        "WITH (UPDLOCK,HOLDLOCK) WHERE [SingletonKey] = 1")
    .SingleAsync(cancellationToken);

generation.Generation = checked(generation.Generation + 1);
generation.StampUpdated(clock.GetUtcNow().UtcDateTime);

The API cache reads that authoritative number before looking up a response. Its key includes the generation, route and normalized query. A cached response from generation 41 cannot satisfy a lookup for generation 42.

The Angular rendering server checks the same idea before reusing a saved HTML page. This excerpt is from its request handler:

const eligible =
  cacheLimit > 0 &&
  !req.headers.cookie &&
  !req.headers.authorization &&
  cacheablePath(req.originalUrl);

const version = eligible ? await generation(base) : undefined;
const cached = version && cache.get(req.originalUrl, version);

When there is a matching entry, the server can reuse it. Otherwise, it renders the page from the public API.

There is another check before new HTML enters the cache:

if (
  eligible &&
  publicHtml &&
  context.generation === version &&
  (await generation(base)) === version
) {
  cache.put(req.originalUrl, {
    html,
    generation: version!,
    expires: Date.now() + 60000,
    headers: [...headers.entries()].filter(
      ([key]) => !['content-length', 'content-security-policy'].includes(key.toLowerCase()),
    ),
  });
}

publicHtml is calculated earlier: the response must be successful, eligible for public caching, and free of private response state. context.generation is the version used by the content reads during rendering.

The second database check avoids storing a page under an earlier version if publication happened while it was rendering. It does not push new HTML into a page someone already has open, and it does not make every in-flight response disappear. The next request checks the current version again.

The response uses Cache-Control: no-cache so a browser must revalidate before reusing stored content. Despite the name, no-cache does not mean that nothing may be stored. MDN explains that distinction.

9. Scheduling pins a revision, not whatever the draft says later

Suppose I schedule Revision 3 for tomorrow, then keep editing tonight. I do not want those later edits to slip into the scheduled article.

The schedule stores the selected revision ID and a UTC publication time:

root.ScheduledRevisionId = revision.Id;
root.ScheduledAtUtc = request.ScheduledAtUtc.UtcDateTime;
root.PublicationState = root.PublishedRevisionId is null
    ? PublicationState.Scheduled
    : PublicationState.Published;

await database.SaveChangesAsync(cancellationToken);

await work.EnqueueAsync(
    root.Id,
    true,
    revision.Id,
    root.ScheduledAtUtc!.Value,
    root.RowVersion,
    cancellationToken);

This excerpt comes after the time and publication checks, and before the surrounding transaction commits. true identifies this work item as an article rather than a project.

Hangfire runs the background work, but the database keeps the business record of what was scheduled. Before publishing, the worker checks that its lease and the article's schedule are still current, then goes through publication validation again. A cancelled or replaced schedule must not become live just because an old job wakes up.

If an article is already public, scheduling its replacement leaves the current revision live until the replacement is published.

What I would verify around this flow

The cases worth testing are the ones where two steps can disagree:

Action Expected result
Save a new title on a live article The public title stays unchanged.
Save from an outdated editor tab The stale version is rejected.
Publish with an unready image Publication fails; the old revision stays live.
Preview, then keep editing The preview still represents its selected revision.
Schedule Revision 3, then edit the draft The schedule still points to Revision 3.
Replace or cancel a schedule Old background work cannot publish it.
Publish while HTML is being rendered The version checks prevent storing mixed-version HTML.
Archive a live article New public reads no longer return it, including cache-backed reads.

This design adds a few steps behind the button, but it makes the editing experience easier to trust. I can save unfinished work, check a version before publishing it, and know which revision the public website is reading.

The part I would keep even in a smaller CMS is that separation: editable content on one side, an explicitly published version on the other.

Further reading

Bujar Ademi

Written by

Bujar Ademi

Senior .NET Developer & Team Lead

← All articles