← work

2020 — now · work

Website and editorial system for SPJ

developer, SPJ Financial & Corporate Communication

A bilingual site and headless editorial system for a financial and corporate communications agency, keeping its news, people, services, cases and transaction record in one place.

React · Redux · Redux-Saga · Laravel · Backpack · Firebase Hosting · Google Cloud Storage

SPJ is a financial and corporate communications agency. Its own copy says it works around financial reports, mergers, acquisitions, IPOs, reorganisations, crises and legal issues, often with companies crossing into or out of the Netherlands. That is work where a news item, a team biography and a transaction record are all part of the evidence that someone knows the subject. The old site had to become a place where that material could be read in Dutch and English, without making editors ask a developer to change a paragraph.

I built the public site in React with Jeffrey, who built the Laravel backend and its Backpack administration. It is deliberately a headless split. React owns the routes, animation, menu and responsive layouts. Laravel owns the things that change: pages, service blocks, team members, client cases, timeline entries and newsroom articles. The browser asks for only the collection a screen needs. An editor publishes it in the admin, rather than editing a component or waiting for a deployment.

One firm, two readers

The language switch is not a translated chrome pasted onto an English site. Pages, article titles and article bodies are stored as translatable fields, and the menu contains both paths where Dutch and English differ. A Dutch reader can use /over-ons while the English reader gets /about-spj. The API only returns a newsroom item if its description exists in the requested language, so an unfinished translation does not leave an empty card in the other language. It is a small rule, but it made the two versions feel maintained rather than one being the forgotten cousin.

The public site still needed to read like one thing. I made the menu configuration the route map and the source map: it names the background image, page template, translated label, nested section and, where needed, the API endpoint. A “who we are” page is an array of a text section, team and timeline. The contact page is a contact record plus a short team view. That kept the navigation and the visual composition together, while the prose and people remained editable elsewhere.

Editors change structured records in Backpack. Laravel exposes them as JSON; the React app turns the menu configuration and those records into the public pages. Images live in Google Cloud Storage rather than in the deployed frontend.

The homepage has a limit

The front page is a grid of current writing with two large links into the firm: who it is and what it does. A wall of equally heavy cards would have made the page long and repetitive, especially on a phone. The code asks for ten recent articles, then fills at most ten grid units. An article with an image takes two units, a text card takes one, and only the first three images survive. The result is a front page that keeps the same rhythm whether the recent material is mostly press releases or mostly illustrated blog posts.

Images were another constraint. Client logos, portraits and news images are content, but embedding them in a React build means a new build for every upload and turns the deploy into a filing cabinet. The backend accepts the image data from the admin, converts portraits and news images to JPEG, client marks to PNG where appropriate and leaves SVG marks alone, then stores the result on Google Cloud Storage. The frontend recognises the stored path and swaps it for the public upload URL. Editors get an upload field; visitors get an ordinary image URL.

I also gave the backgrounds several actual sizes. The page selects a 1k, 2k, 4k, tablet portrait, phone portrait or phone landscape image with media queries. It changes only after the next background has loaded, keeps the outgoing one for two seconds and then removes it. There was an iOS overscroll bug in that fixed backdrop during the work. The fix was not a grand rewrite. It was a specific correction to the banner and mobile layout, because a site about steadiness should not expose a white strip when someone pulls the page down.

SPJ desktop homepage with a horizontal navigation bar, a red “the story behind the numbers” banner, and a tiled grid of links and recent news over a photograph of a tiled building facade.
The homepage treats two firm pages and recent writing as the same grid.
SPJ newsroom on a phone with the SPJ logo, language switch, hamburger menu and stacked article cards. The first card has a colourful illustration of overlapping profile silhouettes.
The newsroom on a phone: one column, the same bilingual controls.

2 images — swipe or scroll sideways

A newsroom that does not lose its way

The newsroom carries three kinds of article: SPJ news, AMO news and a blog. It needs to offer a changing stream without burying older work, and it needs to let a reader narrow the stream without landing in a different, half-loaded version of the page. Laravel paginates the published records in reverse creation order and accepts a language, page size, search term and type. The normal list is five items per page. The front page asks for more because it is arranging a grid, then its promoted cards are excluded from the ordinary first page so the same article is not shown twice.

Redux holds articles by ID and lists by a key made from language and optional type. That means an English blog list and the English all-news list can coexist, and returning to one does not need another request. A single article is also stored by ID, with Laravel supplying the next and previous readable article in each language. The reader can move through the archive without being thrown back to the list to find their place.

The other collections are quieter but follow the same discipline. Cases and team members are fetched once and kept only while their small client cache is valid; a page that opens a client case does not refetch every logo to open one description. The backend sorts people, service blocks and transaction entries by an editor-controlled weight, rather than depending on the accidental order in which they were added. That gave SPJ a practical way to put a new colleague first, move a service up, or correct a transaction sequence without a code change.

Search was the part that initially looked simpler than it was. A search request on every keystroke is a fine way to turn a useful text box into a queue of stale results. The finished client waits 500 milliseconds, takes only the newest search action, and asks the server for up to 100 matching records. The backend makes the title and description comparison case-insensitive. It also filters by article type before pagination. A search for an English blog item therefore does not have to compete with an unrelated page of SPJ news.

The same endpoint feeds the homepage, the newsroom, its type filter, its search and the sitemap. The page does not invent a second catalogue for any of those jobs.

The unglamorous routes

The public app is a single-page application, which is convenient until a reader follows an old link from a press release or a search result. I added a small map of former article paths to their current newsroom IDs. If a matching old path arrives, the app sends the reader to the current language route. For routes it does not recognise, it renders a proper 404 view. Firebase sends every normal route to index.html, while /sitemap.xml goes to a function that calls Laravel’s news-sitemap endpoint. Search engines get a route list from the same records the newsroom reads.

The failure was more ordinary: the language switch originally behaved like a navigation reset. On a small screen it closed the menu while a person was choosing a language, which made the control feel unreliable. I changed the UI state so a language change preserves the mobile menu, and made route parsing determine the language and page source in one place. It is not a feature anyone will point at in a presentation. It is the difference between a bilingual menu being a choice and being a small argument.

The timeline, client list and team use the same arrangement. Laravel orders editable records by their weight fields. The client page filters a single loaded list by its sector index and opens a case description in place, keeping the external client link available. The team can be a full page or the short contact-page version. A current public page can still show the work without hard-coding a single person, client mark or transaction into the frontend.

The frontend deploy is intentionally plain: Firebase Hosting serves the built React files and rewrites application URLs to index.html. That choice left one clear boundary. The site can be updated for a layout or interaction change by publishing the frontend, while adding an article, a client or a portrait remains an editorial action in the admin. For a communications agency whose subject matter changes with the market, that separation mattered more than making the content system clever.