# Harlan Wilton
> Open source developer, contributing to the Vue, Nuxt, and Vite ecosystems.
Canonical Origin: https://harlanzw.com/
## LLM Resources
- [Full Content](https://harlanzw.com/llms-full.txt): Complete page content in markdown format.
---
- **Page:** Open Source Developer
- **Source:** https://harlanzw.com/
- **Description:** Independently funded open-source developer at Nuxt & UnJS.
::home-duality
#human
:::prose
# Harlan Wilton
Independently funded [open-source](https://github.com/harlan-zw){title="Visit my GitHub"} at
[:icon{name="logos:nuxt-icon" .w-5.h-5.inline} Nuxt](https://nuxt.com/){title="Open Nuxt.com"} &
[:unjs-logo{.mx-1 alt="UnJS icon"} UnJS](https://unjs.io/){title="Open UnJS.io" .inline-flex.items-center.whitespace-nowrap}.
Based in Melbourne, Australia.
Author of [Unlighthouse](https://github.com/harlan-zw/unlighthouse), [Unhead](https://github.com/harlan-zw/unhead) and [Nuxt SEO](https://nuxtseo.com).
:::
:::home-updates
:::
#agent
:::prose
## Harlan GitHub Agent
I triage issues, review code and prepare pull requests.
Built with [Harlan Agent Kit](https://github.com/harlan-zw/harlan-agent-kit). Running on [🐷 Hogwild](https://hogwild.harlanzw.com/).
:::
::
---
- **Page:** Talks
- **Source:** https://harlanzw.com/talks
- **Description:** This is a directory of the talks I've given either in video or slide format.
## Videos
- :icon{name="logos:youtube-icon" .mr-2} **Nuxt Nation 2024**: [An Introduction to Nuxt Scripts](https://www.youtube.com/watch?v=V-vPRhUyhDY)
- :icon{name="logos:youtube-icon" .mr-2} **Vue Fes Japan 2023**: [Getting your head around your \
](https://www.youtube.com/watch?v=gwYFwUcxia8)
- :icon{name="logos:youtube-icon" .mr-2} **Nuxt Nation 2022**: [Supercharged SEO head management and practical SEO tips](https://www.youtube.com/watch?v=UC-U0NJowS4)
- :icon{name="logos:youtube-icon" .mr-2} **Laravel Meetup Sydney 2019**: [Laravel 5.8, Laradoc and Bref.sh](https://www.youtube.com/watch?v=nZomdslY9y8)
## Slides
- **Vue Fes Japan 2023**: [Getting your head around your \](https://vuefes23.harlanzw.com/1)
- **Nuxt Nation 2022**: [Supercharged SEO head management and practical SEO tips](https://talk-supercharged-head-management.vercel.app/1)
- **Laravel Meetup Sydney 2022**: [To Vite and Beyond: A history and future of bundling](https://to-vite-and-beyond-a-history-and-future-of-bundling.vercel.app/)
---
- **Page:** Fighting GitHub CI bills with my own homelab
- **Source:** https://harlanzw.com/blog/fighting-github-ci-bills-with-my-own-homelab
- **Description:** Moving GitHub Actions onto Hogwild, with live runner cost estimates, memory limits and the homelab maintenance that followed.
Getting agents to write more code gave me more CI to run.
Every proposed fix needed checks. Review found something, another commit arrived, and the checks ran again. Multiply that by several projects and a service that keeps working after I've closed my laptop.
I wanted that work off my GitHub bill. I also wanted it off my desktop.
So I bought a small computer, named it Hogwild, and gave myself a second job maintaining it.
## What the runners would have cost
This reads the completed job totals from Hogwild. The amount updates as the runner records more work.
::article-runner-savings
::
I call this estimated hosted cost avoided because I haven't reconciled it against an invoice. It prices the recorded runtime at GitHub's standard Linux x64 rate. A job on GitHub's machine may take a different amount of time.
The history also includes earlier self-hosted runner work, before the move to Hogwild. It isn't all work this new machine completed.
[GitHub includes hosted minutes for private repositories, and standard hosted runners are free for public repositories](https://docs.github.com/en/billing/concepts/product-billing/github-actions). My open-source projects already benefit from that. Private project builds were the reason to move more work home.
The calculation rounds each job up to a whole minute before adding the total. Two jobs lasting 61 seconds each count as four minutes. Rounding their combined runtime would count three.
Here's the equivalent TypeScript for the calculation used by the runner's job history:
```ts
function billableMinutes(
startedAt: number,
completedAt: number,
) {
return Math.ceil((completedAt - startedAt) / 60_000)
}
const hostedCostUsd = jobs.reduce((total, job) => {
const { startedAt, completedAt } = job
return total + billableMinutes(startedAt, completedAt)
}, 0) * 0.006
```
The [US$0.006 rate](https://docs.github.com/en/billing/reference/actions-runner-pricing) is for GitHub's standard two-core Linux x64 runner, checked in September 2026. This gives me a consistent comparison. It doesn't tell me when the computer pays for itself.
## The computer
Hogwild is a Minisforum MS-02 Ultra with an Intel Core Ultra 9 285HX. I bought a 2 TB Kingston KC3000 SSD and 32 GB of Crucial DDR5 RAM for it.
::article-hogwild-hardware
::
Those are my purchase figures in Australian dollars. The runner estimate above is in US dollars. I haven't measured electricity use well enough to add a credible running cost.
The machine also runs [my GitHub agent](/blog/building-my-software-factory-on-github), the [Hogwild status page](https://hogwild.harlanzw.com/), and services like Jellyfin. Charging the entire purchase to CI would be a choice, too.
## What about electricity?
I don't have a wall-meter reading for Hogwild yet. CPU utilisation can't tell me what the whole machine draws.
[ServeTheHome measured roughly 19–21 W at idle and 145 W under sustained load](https://www.servethehome.com/minisforum-ms-02-ultra-review-intel-new-home-lab-king/4/) on an MS-02 Ultra. My services, operating system and network cards can change that. I've used 50 W average as a scenario, rather than treating an idle measurement as a day of CI.
At 50 W, running around the clock uses 438 kWh a year. I use 28.2¢/kWh as a local benchmark, the simple average of the [five Victorian Default Offer residential flat tariffs for 2026–27](https://www.esc.vic.gov.au/sites/default/files/documents/Victorian%20Default%20Offer%202026-27%20Price%20Determination.pdf), including GST. That puts electricity at about A$124 a year.
This is a tariff benchmark, rather than a measured average of Melbourne household bills. It excludes the daily supply charge I already pay to connect my house.
::article-runner-projection
::
The projection uses the last seven complete days of runner activity. Today's partial total stays out of the average. Seven days is a short sample, so a busy release week can make the result look much better than a quiet month.
The chart charges the full A$2,773 purchase to CI, converts hosted charges to AUD, then adds five years of electricity. It also subtracts an allowance for included GitHub minutes. The controls let you change the allowance and exclude work that would already run free on GitHub.
I wouldn't buy the machine on this projection alone. I want to see a few months of work and a measured power average before calling it my actual saving.
## One container, one job
The runner supervisor watches the configured repositories for queued work. A signed `workflow_job` webhook can wake it early; polling catches work if that notification doesn't arrive.
Before it registers a runner, it checks whether the host has capacity. Then it creates a just-in-time runner in a fresh container. The runner takes one job and exits.
The job container runs as a non-root user. It gets no Docker socket or host filesystem mounts. The supervisor creates the registration configuration on the host and passes it through standard input.
That leaves the workflow change fairly small. A job selects one of the labels my supervisor serves:
```yaml
jobs:
test:
runs-on: [self-hosted, harlan-desktop-ci]
steps:
- uses: actions/checkout@v4
- run: corepack enable
- run: pnpm install --frozen-lockfile
- run: pnpm test:run
```
That's a shortened example, assuming the runner image already has the project's Node version. The `harlan-desktop-ci` label survived the move. I haven't renamed it yet.
Fresh containers help keep one job's files out of the next job. I still have to maintain the runner image and decide which repositories may execute code on my hardware.
## The first budget was RAM
An earlier desktop runner measurement put a Nuxt SEO deploy at 10.7 GiB of memory. A few builds arriving together could consume the host before CPU became the problem.
The supervisor now reserves memory before starting a job. A reservation spends part of the host's available capacity. A container limit caps how much that individual job can use.
Here is a row from Hogwild's runner configuration, wrapped for reading:
```text
repository: harlan-zw/nuxtseo.com
labels: harlan-desktop-deploy,nuxtseo-deploy
warm runners: 0
maximum runners: 1
CPUs: 12
memory reservation: 13g
memory limit: 16g
memory plus swap: 20g
```
The reservation and limit deliberately differ. The supervisor budgets 13 GiB before accepting the deploy, while the container has room to exceed that estimate.
I also give waiting deploys the next available capacity. Otherwise a steady stream of smaller checks can keep taking the space a deploy needs.
Getting the limits wrong produces misleading failures. A `gscdump.com` cold build measured 7.82 GiB, close enough to its old 8 GiB limit that the container could kill it mid-bundle. The same commit passed on the desktop, where the job had more memory.
I had to fix the runner configuration before another code change could tell me anything useful.
## Giving an agent the right keys
Hogwild runs an agent that reads public issue text. It also runs a supervisor that can create containers. I don't want those to share an account with the same permissions.
The account split gives the agent ordinary user access. A separate admin account owns host administration and Docker access.
[PR #250 in Harlan Agent Kit](https://github.com/harlan-zw/harlan-agent-kit/pull/250) adds a Hogwild skill and an `hw` command to make that distinction explicit. The proposed commands include:
```sh
hw status
hw logs runner -n 100
hw runners
```
For commands on the machine, the caller must choose an account:
```sh
hw run agent whoami
hw run admin whoami
```
The agent route refuses `sudo`. The useful part of that PR is the account map: a failed privileged command should send administrative work to the admin account. It shouldn't become a reason to give the agent more permissions.
That is still a proposed change as I write this. The pull request has the implementation and review history.
The live figures in this article use a separate read path. Hogwild's collector reads local services and returns a small summary to this site's server, authenticated with a dedicated token. The browser gets the summary. It never gets the agent's dashboard password.
For the homepage's latest work, the server checks each reference against GitHub without authentication before exposing a title or link. Private work stays out of that list.
## I own the failures now
On 10 September, a build stalled fetching a font from `fonts.gstatic.com`. Docker was trying a DNS server that the firewall blocked. The delay then collided with the HTTP client's timeout.
There was nothing wrong with the font import. There was something wrong with my network.
Runner updates are another small tax. The runner version in the image can fall behind GitHub's version, leaving each fresh runner to download an update before doing useful work. Updating the image removes that repeated setup.
Even restarting the supervisor needs care. It drains running jobs before stopping. I have to leave it time to finish, or decide that interrupting those builds is worth it.
I still use GitHub-hosted runners where they make sense. Hogwild gives the private builds somewhere else to run, and I can see their memory use and cost comparison on a page I control.
I still need to put a meter on Hogwild. That will replace the largest electricity assumption in the chart.
---
- **Page:** Building my software factory on GitHub
- **Source:** https://harlanzw.com/blog/building-my-software-factory-on-github
- **Description:** How I built a software factory around GitHub issues, AI agents and pull requests, and the work it still leaves for me.
I wanted my weekends back.
For several weeks I'd been running the same health-check skills against my projects. Check Sentry. Look at what broke overnight. Work out which issues needed attention.
The procedures improved as I used them. Eventually I was changing them less, but I still had to open my laptop and start them.
That was the reason to build my software factory.
I call it Harlan GitHub Agent. It lives in [Harlan Agent Kit](https://github.com/harlan-zw/harlan-agent-kit), runs on my home server, Hogwild, and uses GitHub issues and pull requests to keep the work visible.
It produces PRs. I still review most of them.
## What I mean by a software factory
The frame around this map is me. I decide what to work on and what ships, while the agents move changes through the loops.
::article-figure
```yaml [props]
src: /blog/ai-workflow/original-factory-map.webp
display-width: '1000'
alt: Factory map with Harlan owning the code, review, close off and monitoring loops
caption: Code, review, close off and monitoring, with human ownership across the whole process.
width: '1696'
height: '716'
```
::
The actual service adds a poller, SQLite journal and scheduler to keep work moving between agents and GitHub.
I ended up maintaining all of those pieces too.
::article-figure
```yaml [props]
src: /blog/ai-workflow/original-factory-architecture.webp
display-width: '1200'
embed: /blog/ai-workflow/factory/index.html
alt: Interactive Harlan GitHub Agent architecture
caption: Follow Intake, Tasks and Agents, or Publication. Select a node to inspect its source references. Use the zoom controls to read the details.
width: '1920'
height: '1080'
```
::
This describes the service in September 2026. By the time you read it, I will probably have changed parts of it again.
If you're still dealing with broken agent output or conflicting edits, I'd start with [optimizing your development workflow with AI](/blog/improving-your-development-workflow-with-ai). Those problems follow you into a factory.
## Why GitHub?
My projects already had issues, branches, pull requests and CI on GitHub. That gave the factory somewhere to get work and somewhere to put the result.
It also left me a familiar place to inspect it. I can open a PR, read the diff, see the checks and decide what to do.
The service reads issues, PRs, reviews and check runs. A poller records what it sees in a SQLite journal. The scheduler turns that state into tasks and gives available agents work.
An agent takes one task in an isolated worktree. It might triage an issue, implement a change, review a PR or repair a finding. Those are different jobs, with different instructions.
The result returns through the publication gate. That part of the service owns the GitHub writes and rechecks the relevant commit before publishing a result.
That last check matters. While an agent is reviewing a PR, another commit can arrive. A review of the old code must not become a claim about the new code.
The [September architecture snapshot](https://github.com/harlan-zw/harlan-agent-kit/tree/ca1cb363a452ecadc03648df9613f38e4585560e/packages/harlan-github-agent) separates reading GitHub state, doing the work and publishing the result. GitHub gives me the shared record; the service handles the work between observations.
## Following one issue
An issue first needs enough information to act on. Triage checks the premise and decides whether it is ready for implementation, needs a specification, needs more information or should wait.
If it is ready, an agent gets a worktree and starts the task. For a bug, I want a reproduction and a failing test before the fix. The output should be a change someone can review.
A separate Review agent challenges that change. If it finds a defect the system may repair, a fresh Repair agent takes the finding. The new commit goes through review again.
I don't want the author marking its own homework. Separating the roles also gives the reviewer a smaller job: find what's wrong and show the evidence.
The review result names the commit it examined and records uncertainty. This READY comment shows what that looks like. Even at 95/100, it explicitly asks for a human merge decision.
::article-figure
```yaml [props]
src: /blog/ai-workflow/ready-review.webp
display-width: '900'
alt: Agent review marked READY at 95 out of 100, stating that human merge approval is still required
caption: A READY review with evidence, confidence and an explicit request for human approval.
width: '1830'
height: '624'
```
::
### Routine changes and judgement
My factory policy permits selective auto-merge. The label delegates that authority for work that needs no judgement.
::article-figure
```yaml [props]
src: /blog/ai-workflow/original-review-ideas.webp
display-width: '1000'
alt: Review diagram showing independent reviewers, selective auto-merge and a bounded review queue
caption: Independent review, selective auto-merge and a bounded queue. The services and queue slots are illustrative.
width: '1760'
height: '597'
```
::
A high confidence score isn't permission. Neither is the fact that a previous version of the PR passed review. If the scope changes, the decision needs to change with it.
I still review most PRs. I also own the consequences of the ones I allow the service to merge.
## The boring handoff that broke everything
One of the more useful failures had very little to do with generated code.
The service triaged an issue, then looked up that triage session when implementation started. The lookup included the default branch's commit.
If main advanced in between, the lookup no longer matched. The issue hadn't changed. Its title and body were the same. Work still stopped with:
```text
The issue changed before work started.
```
Batch planning made the timing worse. By the time a task started, main had often moved. Re-triaging sent the same work around again.
The [fix in PR #183](https://github.com/harlan-zw/harlan-agent-kit/pull/183) keyed the triage session on the issue's own state, independently of the default branch tip.
The lookup changed from this:
::expand{width="1100"}
```diff
- const scopeDigest = issueSnapshotDigest({ ...snapshot.value, baseSha: prepared.value.defaultBranchSha })
+ const scopeDigest = issueSnapshotDigest(snapshot.value)
const sessionId = options.store.getWorkerSession(
task.repository, task.issueNumber, 'issue_triage', scopeDigest,
)
```
::
That excerpt is from the implementation worker, with the lookup wrapped for readability. The same change also had to happen when triage stored the session. Fixing only one side would leave the keys disagreeing.
Implementation needs a checkout of the code it will change. The triage decision needs to identify the issue it examined. An unrelated merge should not invalidate it.
I had modelled the application state incorrectly. No prompt was going to repair the lookup.
The controller also needed recovery for expired worker ownership and limits on repair rounds. An unattended service has to know what work is still owned, what can resume, and when to stop trying.
## Giving the agents somewhere to work
The service uses Worktrunk to give each task an isolated checkout. Main stays clean. Setup prepares the dependency graph and local state before the agent starts.
The pnpm store can be shared, but each checkout needs its own `node_modules` and generated files. Writable state belongs to the task too.
I use the same standing instructions and task skills across my interactive work and the factory. Those instructions cover things like error handling, tests, review evidence and how a task finishes.
That gives me one place to improve a procedure after a failure. It also gives me another thing to verify: which version of the instructions and tools is a worker actually using?
Shared instructions don't make untrusted input safe. Issues, comments and source files are still input to an agent. The service needs explicit authority for its actions, and an agent's proposed result still needs checking before publication.
I wouldn't treat a synced `AGENTS.md` as a security boundary.
For example, the service checks repository permission when a caller asks for a write credential. The [write gate](https://github.com/harlan-zw/harlan-agent-kit/blob/ca1cb363a452ecadc03648df9613f38e4585560e/packages/harlan-github-agent/src/github-write-gate.ts) contains this check:
```ts
getToken(repository, access, signal) {
if (!writeAccess.has(access) || options.mayWrite(repository))
return options.source.getToken(repository, access, signal)
return Promise.resolve(err({
repository,
message: repositoryQuarantineReason(repository),
}))
}
```
`writeAccess` contains the contents, issue and workflow write permissions. Read requests can pass through. A write request for a repository I haven't enabled returns an error before the caller gets a token.
## Hogwild 🐷
Hogwild runs the factory and my self-hosted GitHub Actions runners. These are current readings from the machine and the agent service.
::article-live-stats
::
I wrote more about [moving CI onto my homelab](/blog/fighting-github-ci-bills-with-my-own-homelab), including a live runner cost comparison.
CI cost helped motivate the move, but putting runners on a machine introduced a different problem. Heavy builds competed for the same memory.
An earlier runner configuration recorded a Nuxt SEO deploy peaking at 10.7 GiB. Starting more containers didn't create more physical RAM.
I needed admission based on available capacity. The supervisor reserves memory before starting heavy work, releases it afterwards, and leaves room for waiting deploys.
A container's hard limit and a reservation do different jobs. The limit stops one job using too much. The reservation helps stop the host accepting too many jobs at once.
There is a [Hogwild status page](https://hogwild.harlanzw.com/) for seeing the machine's activity. Its runner cost estimate compares that activity with equivalent hosted Linux runner charges. It doesn't subtract hardware or power, and included minutes or public repository allowances can change what I'd actually have paid.
I don't have a measured payback claim to make here. The machine gives me capacity I can manage, and another system I have to maintain.
## The queue needs a limit
The September example configuration sets the open PR cap to eight:
```yaml
# No new issue work starts when open pull requests
# reach this limit.
max_open_pull_requests: 8
```
That is an excerpt from the [versioned configuration](https://github.com/harlan-zw/harlan-agent-kit/blob/ca1cb363a452ecadc03648df9613f38e4585560e/packages/harlan-github-agent/config.example.yml). The cap pauses new issue work. Review and repair still need to run, otherwise the service cannot clear the queue.
Once the queue is waiting on my review, more agents mostly add PRs for me to read.
Repair needs a limit for the same reason. An agent can keep attempting a problem without getting closer to a change I'd accept. I want the service to stop and surface the blocked work.
Cost has to include that failed work too. A cheap model request tells me very little about the cost of a completed task if it needs repeated attempts and repairs.
I left provider prices out of this article. They change quickly, and a subscription allowance isn't the same thing as API billing. The number I'd want to compare is what useful, reviewed work costs under the execution model I'm actually using.
## Work can arrive without me
Scheduled routines are the part that solved my original problem.
Sentry check-ins look for production errors. Daily check-ins combine runtime and usage signals. Their findings can become issues or proposed repairs, which feed back into GitHub for the factory to handle.
I also built a feedback form on a clone of the MelbJS site that files GitHub issues. Feedback enters the same queue as everything else. An issue may need clarification, or its PR may wait for review.
There is even a Factory review routine that proposes changes to the factory itself. Those proposals join the work I need to judge.
## What I still do
I decide which projects the factory may work on. I set the constraints, review most changes and deal with the consequences when something goes wrong.
For Unhead, a technically correct change can still create an unwanted breaking change or affect an integration the agent hasn't considered. That judgement doesn't disappear because the checks are green.
The factory has taken over starting and following a lot of maintenance work. It has also given me controller bugs, runner capacity and agent instructions to maintain.
I wanted to stop opening my laptop just to tell an agent to check the same things again. Now those checks can start without me. Deciding what to do with the results is still my work.
---
- **Page:** Optimizing your development workflow with AI
- **Source:** https://harlanzw.com/blog/improving-your-development-workflow-with-ai
- **Description:** What I changed when AI agents wrote broken code, shared the same files, and produced more pull requests than I could review.
I use AI heavily across my open-source projects and Nuxt SEO Pro. For a while, my workflow was pretty simple. Give an agent a task, let it work on main, commit everything and push.
Fast. Also a good way to send bugs to people using your product.
I knew pull requests would help. But adopting them brought more work: separate environments, conflicts, reviews I had to remember to start, and CI that couldn't keep up.
Every improvement let the agents do more. Then something else became the problem.
It's tempting to look at someone running a room full of agents and start there. I've ended up [building my own software factory on GitHub](/blog/building-my-software-factory-on-github), but most of the useful changes happened before that.
They came from three questions.
## Can I trust my agent's code?
On 28 August I merged an agent-written Stripe integration into Nuxt SEO. It touched 23 files. The checks were green and the description said the agent had implemented and verified it.
I merged it without reading the code.
::article-figure
```yaml [props]
src: /blog/ai-workflow/stripe-pr.webp
display-width: '760'
alt: Merged Stripe integration PR 636, showing 23 changed files and the agent disclosure
caption: The PR I merged on 28 August. The payment failure below is a separate teaching example.
width: '1792'
height: '1492'
```
::
The description looked convincing. Checking every claim would have taken me longer than the agent spent writing it.
### Where did the error go?
One recurring problem was silent error handling. An older version of my admin code turned a failed Stripe balance request into `null`:
```ts
const balance = await stripe.balance.retrieve()
.catch(() => null)
```
The request failed, but the cause disappeared. Whatever happened next had to work with `null`.
Take this simplified payment handler:
```ts
try {
await stripe.paymentIntents.confirm(paymentIntentId)
}
catch {
// Payment failed, let the user try again.
}
```
This is an illustration, not the code from that merged PR. The comment sounds reasonable. It doesn't tell the caller what failed or leave anything useful to investigate.
There is another detail hiding in this tiny example. A resolved confirmation request can still require customer authentication. The caller needs to inspect the PaymentIntent status. [Stripe's confirmation documentation](https://docs.stripe.com/api/payment_intents/confirm) describes those transitions.
I started by writing down a standing rule: no silent catches. Expected failures should have an explicit result. Unexpected failures should propagate or be reported with enough context to investigate.
The next agent could still write the same bad code.
So I added a lint rule. It catches shapes like `.catch(() => null)` and empty catch blocks. A hook runs lint after edits so the agent gets feedback while it is still working.
The limits matter here. My rule allows comment-only catches, including the illustrated one above. It can reject a code pattern. It can't decide whether a payment failure reached the right person.
### Give failure a shape
My TypeScript preferences borrow from Effect, without requiring Effect as a dependency. Expected failures become tagged values that the caller must handle. Unexpected infrastructure failures remain errors.
For a payment, that means distinguishing a card decline from a service failure. The caller needs to know whether to ask the customer for another payment method or report a problem.
Here is the same handler with explicit results for a declined card and a rate limit.
::article-disclosure{summary="Show the complete error handling"}
Illustrative excerpt. The request-bound logger, Stripe client and result helpers are set up outside this block.
The PaymentIntent already exists with its payment method configured. A returned payment still needs its status handled.
:::expand{width="960"}
```ts
try {
const payment = await stripe.paymentIntents.confirm(paymentIntentId)
// The caller must still check the returned payment status.
// A successful request can require customer authentication.
log.context({ paymentStatus: payment.status })
return ok(payment)
}
catch (error) {
log.level('error')
log.context({ paymentStatus: 'failed' })
// A declined card becomes a result the caller can handle.
if (error instanceof Stripe.errors.StripeCardError) {
log.warn('payment.declined', error)
return err({ _tag: 'PaymentDeclined' as const, code: error.code })
}
// Keep retry information when Stripe provides it.
if (error instanceof Stripe.errors.StripeRateLimitError) {
log.warn('payment.rate_limited', error)
return err({ _tag: 'RateLimited' as const, retryAfter: error.headers?.['retry-after'] })
}
// Record unexpected failures, then let them propagate.
log.error('payment.failed', error)
throw error
}
```
:::
::
I want sensitive fields redacted before errors leave the application. Nuxt SEO has shared logging rules for names, redaction and where events go. That gives each handler something consistent to use.
Then there is production. Sentry can surface an exception, but some broken journeys never throw. A user may retry several times and give up.
Logs and usage signals can help find those cases. A drop in completed payments is a reason to investigate. It doesn't tell you whether the cause is a bug, confusing UI or something else.
By this point, fixing a catch had taken me from an instruction file to production monitoring.
### More tests didn't give me more confidence
Agents are very happy to write tests. Some of mine checked that a file contained a string, or that the source had the structure the agent had just written.
Those tests gave me more code to maintain. They didn't tell me much about whether the feature worked.
My testing skill now asks for concrete input, a call to an exported function, and an observable result. For a bug fix, I want to see the test fail before the fix.
I also use gitignored scratch tests. An agent can write a probe to understand a problem, run it, then throw it away. If it captures behaviour that needs protecting, it belongs in the maintained suite.
A rank-check bug in Nuxt SEO made that distinction painfully concrete. The data provider's spending limit stopped a request. My code treated the missing result as a missing ranking and emailed a customer about a drop that hadn't been measured.
This regression test starts with a keyword at position 21. When the fetch returns nothing, the saved position must stay at 21. This is an excerpt from the test, with its fixture helpers left out:
```ts
const db = await seed(21)
const outcome = await recordRankCheck(db, {
keywordId: KEYWORD,
siteUrl: 'https://ranknm.example',
competitorDomains: [],
fetchSerp: async () => undefined,
now: NOW,
})
expect(outcome).toEqual({ _tag: 'not_measured' })
expect(await snapshots(db)).toEqual([
{ day: '2026-08-27', position: 21 },
])
```
Checking for a string in the source wouldn't have caught that email. This test checks what the application writes.
## Do my agents work effectively in parallel?
Asking four agents to work at once is easy. Giving them four usable environments took more effort.
A branch alone doesn't give an agent a separate directory. A worktree does. Each task gets its own checkout, while the primary checkout stays clean on main.
::article-figure
```yaml [props]
src: /blog/ai-workflow/original-worktrees.webp
display-width: '1100'
alt: Worktree diagram showing four agents, shared package files and private task state
caption: Each agent gets a checkout and private task state. Branches and preview names are illustrative.
width: '1808'
height: '649'
```
::
I use [Worktrunk](https://github.com/max-sixty/worktrunk) to manage that setup. Before a task starts, the checkout needs its dependencies, local configuration and writable state ready.
For example, I can start a task from the current main branch like this:
```sh
wt switch --create fix/payment-errors --base origin/main
pnpm install --frozen-lockfile
pnpm exec nuxt prepare
```
Worktrunk creates the checkout. The next two commands prepare that checkout's dependencies and Nuxt files. Project-specific setup still has to provide local configuration and a private database.
Otherwise, the agent spends the first part of the task fixing its environment. Sometimes it changes application code to compensate for an environment that was wrong to begin with.
### Share the package store
The useful distinction is between sharing package files and sharing an installed application.
[pnpm's package layout](https://pnpm.io/symlinked-node-modules-structure) lets worktrees share package data while keeping a separate dependency graph in each checkout. Each task gets its own `node_modules`. Nuxt generates `.nuxt` inside that task's checkout too, because generated files can contain paths specific to it.
Local databases need their own writable state. Sharing a database between two tasks makes it much harder to know what either agent actually tested.
There are less interesting details that still make a difference. I use [Portless](https://github.com/vercel-labs/portless) for stable local preview names, and named browser pages for each task. I also built a JetBrains worktree plugin so I could see the checkouts in my IDE.
I needed the separate checkouts to be easy to find, otherwise I'd work around the setup.
::article-figure
```yaml [props]
src: /blog/ai-workflow/worktree-ide.webp
display-width: '700'
alt: JetBrains worktree panel listing task branches and their latest activity
caption: My JetBrains worktree panel. The 86 entries are worktrees, not concurrent agents.
width: '1282'
height: '1042'
```
::
### Separate checkouts still meet at the same files
Worktrees didn't fix my architecture.
Independent features still passed through shared configuration and registration points. Two tasks could work perfectly well in isolation, then arrive with competing edits to the same block.
I had a real pair of Nuxt SEO PRs that both rewrote the sitemap-failure handling in one crawl job. They needed an explicit landing order and a rebase.
PR #728 added a retry flag. PR #733 changed the event name on the same line. The repository is private, so I've included shortened excerpts to show the overlap:
::expand{width="1100"}
```diff
# PR #728
- logWarn('crawl_audit.degraded', error, { stage, ...context })
+ logWarn('crawl_audit.degraded', error, { stage, ...context, retryable: sitemap.retryable })
# PR #733
- logWarn('crawl_audit.degraded', error, { stage, ...context, retryable: sitemap.retryable })
+ logWarn('crawl_audit.target_site_refused', error, { stage, ...context, retryable: sitemap.retryable })
```
::
Sometimes that's unavoidable. If it keeps happening, I look at the boundary. Can the feature own its configuration? Can the shared module expose a smaller interface? Are the tasks actually independent?
For related work, stacked PRs can make the dependency explicit. For repeated mechanical edits, I use Ripast to preview and apply structured renames and moves, then check the result. Repeating a more detailed prompt wasn't fixing broken refactors in large Vue files.
The same applies to context. `VISION.md` explains what the product is trying to do. `GLOSSARY.md` keeps the names consistent. `DESIGN.md` gives agents shared UI decisions to follow.
Two agents can use different files and still make incompatible product decisions. Separate directories won't catch that.
## Can I keep up with what they produce?
Eventually the agents could produce work faster than I could land it. You can see the current workload below: agents doing work, PRs still open, and CI jobs running or waiting.
::article-live-stats
::
Some of the delay was CI. I was paying for repeated setup and waiting for checks on commits I had already replaced.
::article-figure
```yaml [props]
src: /blog/ai-workflow/original-ci-ideas.webp
display-width: '1100'
alt: CI diagram showing fast PR checks, cheap checks first, superseded runs and scratch tests
caption: Four ways to reduce CI waiting. A smaller PR gate moves some failure detection until after merge.
width: '1760'
height: '560'
```
::
Lint and type checks can reject a change before an expensive build starts. [GitHub Actions concurrency](https://docs.github.com/en/actions/concepts/workflows-and-actions/concurrency) can cancel superseded checks for the same PR. A deployment that needs to finish requires a different policy.
For a PR-only validation workflow, this is enough to cancel an older run when a new commit arrives:
```yaml
name: Validate
on: pull_request
concurrency:
group: validate-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
validate:
runs-on: ubuntu-latest
steps:
# Checkout and install dependencies first.
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm build
```
This is a configuration excerpt. Checkout and dependency setup depend on the project. The order means a failed lint step stops the job before the build starts.
A smaller PR gate is also a tradeoff. Moving checks until after merge means some failures reach main before you discover them. I need to know what I'm giving up and how those failures get reported.
Caching needs the same scrutiny. I removed pnpm caching from one CI setup because it was slower there. A shared package store on my machine and downloading an archive on a hosted runner have different costs.
But faster CI only solves part of it. My attention is still finite.
### Make the review worth reading
I want an agent review to try to disprove the change. Check the premise, follow the failure cases, and show what was actually verified.
This review comment shows the actual navigation the agent checked in two layouts. It also leaves a request error visible in one screenshot.
::article-figure
```yaml [props]
src: /blog/ai-workflow/browser-check.webp
display-width: '1000'
alt: Browser verification comment with fleet and one-site navigation screenshots
caption: The one-site screenshot contains a request error. The comment shows what the agent checked and what still needs attention.
width: '1784'
height: '1038'
```
::
A diagram helps when the change crosses several boundaries. This PR shows where the Bing data comes from and where credentials enter the request.
::article-figure
```yaml [props]
src: /blog/ai-workflow/bing-flow.webp
display-width: '1000'
alt: Bing PR diagram connecting views, Site credentials, bounded requests and the public Bing API
caption: The Bing integration PR. The diagram gives the reviewer a route through the change.
width: '1626'
height: '864'
```
::
A confidence score helps only if it says what remains untested. It doesn't authorize a merge.
With Unhead, I still need to think about framework integrations, breaking changes and bundle overhead. An agent may correctly implement its task while missing a reason I don't want the change at all.
If review is full, another running agent can just produce another waiting PR.
::article-figure
```yaml [props]
src: /blog/ai-workflow/original-review-ideas.webp
display-width: '1000'
alt: Review diagram showing independent reviewers, selective auto-merge and a bounded review queue
caption: Independent review, selective auto-merge and a bounded queue. The services and queue slots are illustrative.
width: '1760'
height: '597'
```
::
## Start with the problem you keep having
If you keep correcting the same mistake, write down the rule. If the mistake has a detectable shape, make a check. If two agents keep editing the same block, look at the task split and the architecture.
You can do any of that with one agent and a normal development workflow.
I built the factory when I wanted trusted maintenance work to start without opening my laptop. I still have to decide what is worth doing and what should ship.
---
- **Page:** Projects
- **Source:** https://harlanzw.com/projects
- **Description:** This is a directory of the open-source packages and tools I've released that are actively maintained.
::prose
This is a directory of the open-source packages and tools I've released that are actively maintained. You can find all of my projects on [GitHub](https://github.com/harlan-zw).
::
---
- **Page:** Blog
- **Source:** https://harlanzw.com/blog
- **Description:** This is a directory of the articles I have written and published that are sometimes updated.
---
- **Page:** How I use AI in open source
- **Source:** https://harlanzw.com/blog/ai-in-open-source
- **Description:** My policy for AI-written code, pull request descriptions and automated reviews across the projects I maintain.
AI is here, and it is rightfully carving out how open source works.
Being an engineer means adapting to the underlying technology, so I'll be using AI heavily in my open-source work.
There's already enough AI slop in open source. AI-generated PR descriptions generally suck.
I don't want to add to it. [Daniel Roe's post on using AI in open source](https://roe.dev/blog/using-ai-in-open-source) helped me settle my policy on working with AI in open source.
## The Rules
- AI never speaks for me. Every public action says it came from an agent.
- Agents may act autonomously only in selected repositories I own (`harlan-zw/*`) or am the only core contributor.
- Other open-source projects including [Nuxt](https://github.com/nuxt/nuxt) will never receive an agent generated PR.
- [AI-written pull request descriptions](https://github.com/harlan-zw/harlan-agent-kit/blob/main/harlan-agent-kit/skills/pr/SKILL.md) stay concise, link to this page and include the disclosure below.
- [Automated reviews](https://github.com/harlan-zw/harlan-agent-kit/blob/main/harlan-agent-kit/skills/adversarial-review/SKILL.md) use one labelled bot comment. It names the reviewed commit and any uncertainty.
- Agents never approve or merge pull requests.
- Fixes use normal commits. Agents never hide or rewrite history.
- Anything ambiguous comes back to me.
### Sites I own
- The [PR owner agent](https://github.com/harlan-zw/harlan-agent-kit/blob/main/harlan-agent-kit/skills/take-ownership/SKILL.md) watches the deployment and runs relevant smoke tests against production.
- Agents may [repair post-merge CI](https://github.com/harlan-zw/harlan-agent-kit/blob/main/harlan-agent-kit/skills/take-ownership/SKILL.md) on the default branch.
## Workflow: Issue Triaging
On selected projects, Harlan Agent Kit [checks new issues](https://github.com/harlan-zw/harlan-agent-kit/blob/main/harlan-agent-kit/skills/issue-triage/SKILL.md) before touching code. It tries to reproduce the problem, checks for duplicates and decides whether there is enough information to act.
If the issue looks valid, the agent starts in an isolated worktree. It writes a regression test, fixes the problem and [opens a pull request](https://github.com/harlan-zw/harlan-agent-kit/blob/main/harlan-agent-kit/skills/pr/SKILL.md). Anything ambiguous comes back to me.
## Workflow: Opening Pull Requests
On projects I maintain myself, I use AI to help write code and pull request descriptions. The descriptions are [tuned for my preferences](https://github.com/harlan-zw/harlan-agent-kit/blob/main/harlan-agent-kit/skills/pr/SKILL.md#step-3-build-pr-content): explain why the change exists, capture useful context and cut the noise.
When Harlan Agent Kit drafts or edits a description, the pull request links back to this page and includes this:
> 🤖 AI disclosure: [Harlan Agent Kit](https://github.com/harlan-zw/harlan-agent-kit) modified this description. [My AI open-source policy](https://harlanzw.com/blog/ai-in-open-source).
The description still needs to help someone review the change. AI involvement is no excuse for a wall of text or claims that nobody checked.
## Workflow: Adversarial Review
When I or someone else opens a pull request on a selected project, my agent runs an [adversarial review](https://github.com/harlan-zw/harlan-agent-kit/blob/main/harlan-agent-kit/skills/adversarial-review/SKILL.md). Codex and Claude Code try to disprove the change. They read the full diff, look for missing tests and follow failures instead of stopping at the first green check.
If the agent finds a real problem and has permission to fix it, it pushes a normal commit. The history stays visible.
The final status is posted as one bot comment. It names the exact commit reviewed, says what remains uncertain and starts with:
> 🤖 Bot review: [Harlan Agent Kit](https://github.com/harlan-zw/harlan-agent-kit) posted this comment. It is not Harlan's personal review or approval. [My AI open-source policy](https://harlanzw.com/blog/ai-in-open-source).
Later runs update the same comment instead of adding more noise. The bot never approves or merges a pull request. That decision stays with a human.
## Misbehaving AI
AI does not like following instructions, so I know it will get this wrong eventually.
AI can write a convincing explanation for broken code. A passing test can miss the thing that matters. A confident review can still be wrong.
I still own the systems I choose to run and the changes they make to my projects.
If a bot gets something wrong, email me or open an issue. I'll work out what failed and make the process better.
---
- **Page:** August 2023: Türkiye, Bulgaria, North Macedonia and Albania
- **Source:** https://harlanzw.com/blog/2023-august
- **Description:** What I got up to in August in my open-source work and my travels.
In August me and my girlfriend continued travelling, getting to 4 new countries: Türkiye, Bulgaria, North Macedonia and Albania.
A typically morning was waking up and trying to piece together the details of where we even where, we were moving fast
after Greece.
We did a quick detour in Türkiye, then the many cramped and sweaty bus getting around the Balkans.
I was surprised to still manage to get 109 hours to work on open-source, but I was starting to feel the burnout of
managing work and travel.
## Open-source
::div{.grid.sm:grid-cols-2.gap-3.sm:gap-8.sm:my-15.my-5}
:::score-card{label="⭐ GitHub stars" :before="6295" :after="6543"}
:::
:::score-card{label="⌛ Hours Worked" :before="204" :after="109"}
:::
:::score-card
```yaml [props]
label: 💸 MRR AUD
currency: true
:before: '1212'
:after: '1240'
```
:::
:::score-card
```yaml [props]
label: 💸 Per Hour AUD
currency: true
:before: '6.23'
:after: '11.76'
```
:::
::
The highlight of the month was getting out [Unhead v1.3](https://github.com/unjs/unhead/releases/tag/v1.3.0) which included
some significant performance improvements.
I continued work on Nuxt Scripts, trying to figure out the scope of the project and how it would work.
The rest of my time went to maintenance of [Nuxt SEO](https://nuxtseo.com).
## 🇹🇷 Türkiye
Our first day in İstanbul was massive, exploring all the stunning Mosque's. You can easily hit up 5 mosques and only walked
a few kms.
::expand
:::div{.grid.grid-cols-2.gap-5.my-15}
::::prose-img{src="/august-mosque-1" alt="Fatih Mosque, İstanbul" no-margin}
::::
::::prose-img{src="/august-mosque-2" alt="The Blue Mosque, İstanbul" no-margin}
::::
:::
::
We stayed in a locals neighbourhood and hit up the family-run restaurants. I didn't know at that point, but I'd be eating
some form of "kebab" and drinking Turkish coffee every day for the next month.
Safe to say, I also took advantage of the Turkish sweets.
::expand
:::div{.grid.grid-cols-2.gap-5.my-15}
::::prose-img{src="/august-kebab" alt="Kebab, Lahmacun, Pide" no-margin}
::::
::::prose-img{src="/august-sweets" alt="İstanbul sweet shop" no-margin}
::::
:::
::
After being thoroughly mosque'd out, we headed to the famous "balloon" town Göreme within the Cappadocia region.
Göreme is stunningly scenic little town that is an Instagram tourist haven and in turn is a bit of a tourist trap.
But we still enjoyed it!
::expand
:::div{.grid.grid-cols-2.gap-5.my-15}
::::prose-img{src="/august-rock-formation" alt="Well-endowed rock formations, Gorkundere Valley" no-margin}
::::
::::prose-img{src="/august-goreme" alt="Sunset at Göreme" no-margin}
::::
:::
::
We did a full-day hike through Rose valley, coming across
impressively well-endowed rock formations, historical abandoned cave churches and a friendly dog
who guided us.
This was one of my favourite hikes, we had the trail almost entirely to ourselves, it was scenic and exploring the caves with
our new dog friend was fun.
::expand
:::div{.grid.grid-cols-2.gap-5.my-15}
::::prose-img{src="/august-hike.png" alt="Start of the Rose Valley hike" no-margin}
::::
::::prose-img{src="/august-cave-outside.png" alt="One of the many abandoned cave dwellings" no-margin}
::::
::::prose-img{src="/august-hike-rope.png" alt="Rope climb featuring a friendly dog" no-margin}
::::
::::prose-img{src="/august-cave-church-2.png" alt="Walking around inside an abandoned Cave Church" no-margin}
::::
:::
::
The balloons were expensive, so I decided to skip. But I did hear good things and the ground view
itself was pretty impressive.
::prose-img{src="/august-balloons" alt="Göreme Sunrise Balloons" no-margin}
::
## 🇧🇬 Bulgaria
Bulgaria was the start of
the Balkans for us, which meant I was in for a lot of cheese pies and more kebabs.
We did a quick couple of days in Plovid, which had a nice old town and some interesting Roman ruins. Then we were
off to the capital city Sofia. It had some interesting architecture and a nice park.
::expand
:::div{.grid.grid-cols-2.gap-5.my-15}
::::prose-img{src="/august-sofia-2" alt="Government of Bulgaria, Sofia" no-margin}
::::
::::prose-img{src="/august-sofia-3.png" label="St. Alexander Nevsky Cathedral, Sofia" no-margin}
::::
:::
::
From Sofia, we went to do Seven Rila Lakes hike, which was one of our highlights.
The hike is a chair lift, followed by a 3-hour ascent to the peek. Along the way you'll see 7 lakes, each at a different altitude.
::expand
:::div{.grid.grid-cols-2.gap-5.my-15}
::::prose-img{src="/august-rila-lake-start.png" alt="Start of the Seven Rila Lake hike" no-margin}
::::
::::prose-img{src="/august-rila-lake-2.png" label="About half way up" no-margin}
::::
::::prose-img
```yaml [props]
src: /august-rila-lake-3.png
label: One of the 7 lakes
alt: One of the lakes of the Sven Rila Lake Hike
no-margin: true
```
::::
:::
::
It was a fun hike, everyone had brought their dogs along and had picnics at the lakes.
You get to the peak, and it's a stunning view - if the clouds aren't blocking it.
::expand
:::div{.grid.grid-cols-2.gap-5.my-15}
::::prose-img
```yaml [props]
src: /august-rila-lake.png
label: Some nice waterfalls near the top
alt: Seven Rila Lake Hike Waterfall
no-margin: true
```
::::
::::prose-img
```yaml [props]
src: /august-rila-lake-fog.png
label: A bit too foggy at the peak
alt: Seven Rila Lake Hike Top Very Foggy
no-margin: true
```
::::
:::
::
## 🇲🇰 North Macedonia
We spent a couple of days in Skopje, a city characterised by an excess of modern statues and monuments. Many of them
artificially made to look historical.
::prose-img{src="/august-skopje" alt="Archaeological Museum, Skopje"}
::
A nice day trip from Skopje is Matka Canyon, you can do some nice hiking, lake swims and kayaking.
::prose-img{src="/august-canyon-hike" alt="Matka Canyon Hike"}
::
The rest of our time in North Macedonia was spent in Ohrid, a beautiful lake town. It seems to be the holiday destination
of choice for anyone in the Balkans who doesn't have easy access to the coast.
Every night we were there, there was a festival on the lake, with fireworks, music and rides.
::prose-img{src="/august-ohrid" label="Quintessential shot of Lake Ohrid"}
::
## 🇦🇱 Albania
We didn't know much about Albania beforehand, we had heard good things from other travellers though and we were excited.
They told us about the friendly locals, and it was beautiful nature.
Our first stop was Gjirokastër, Albania. Gjirokastër is a UNESCO world heritage site, with a classic old town and castle.
We got a good workout climbing up to the castle, which had some nice views.
::prose-img{src="/august-girokaster.png" label="Sunset Gjirokastër, Albania"}
::
We also did a day trip to the Blue Eye, which is a natural spring that is a stunning blue colour. You can jump from
a few metres up into the water, which was fun.
::prose-img{src="/august-blue-eye.png" label="Blue Eye Lake, Albania"}
::
From there, we hit the coastal town of Himarë. Himarë is one of the remaining chill beach towns in the Balkans, that
hasn't been completely overrun with tourism development.
::prose-img{src="/august-himare.png" label="Himarë sunset"}
::
Our last stop for Albania was the Northern town of Shkodër. From Shkodër
you can go and stay in the Albanian Alp town of Theth.
::expand
:::div{.grid.grid-cols-2.gap-5.my-15}
::::prose-img
```yaml [props]
src: /august-theth.png
label: The view from where we were staying
alt: Theth Mountains
no-margin: true
```
::::
::::prose-img
```yaml [props]
src: /august-theth-2.png
label: Another view from where we were staying
alt: Theth Mountains
no-margin: true
```
::::
::::prose-img
```yaml [props]
src: /august-theth-3.png
label: Mountainside from a hike
alt: Theth Mountains
no-margin: true
```
::::
::::prose-img
```yaml [props]
src: /august-theth-4.png
label: One more - I really liked them!
alt: Theth Mountains
no-margin: true
```
::::
:::
::
We spent 4 days in Theth, doing the Volbona pass hike and chasing waterfalls. It was really cosy and relaxing to be surrounded by
the mountains, it was a good recovery from the fast pace of the Balkans.
::expand
:::div{.grid.grid-cols-2.gap-5.my-15}
::::prose-img
```yaml [props]
src: /august-theth-mountain-cow.png
label: A mountain cow towards the peak!
alt: Cow in Theth Mountains
no-margin: true
```
::::
::::prose-img{src="/august-theth-mountain-2.png" label="View from the peak, it was really foggy." no-margin}
::::
:::
::
## September Plan
- 🚌 Montenegro, Bosnia & Herzegovina, Croatia, Slovenia, Hungary, Czechia
---
- **Page:** July 2023: Nuxt Release Week, Turning 29 and Exploring Greece
- **Source:** https://harlanzw.com/blog/2023-july
- **Description:** What I got up to in July in my open-source work and my travels.
## Open-source Metrics
::div{.grid.sm:grid-cols-2.gap-3.sm:gap-8.sm:my-15.my-5}
:::score-card{label="⭐ GitHub stars" :before="6112" :after="6295"}
:::
:::score-card{label="⌛ Hours Worked" :before="87" :after="195"}
:::
:::score-card
```yaml [props]
label: 💸 MRR AUD
currency: true
:before: '867'
:after: '966'
```
:::
:::score-card
```yaml [props]
label: 💸 Per Hour AUD
currency: true
:before: '10.31'
:after: '4.9'
```
:::
::
## What I Got Up To
### Nuxt Release Week
While working and travelling it's difficult to build momentum.
I have a constant pulse of things requiring attention: GitHub issues,
[Discord](https://discord.com/invite/5jDAMswWwX) support questions, client issues etc.
I like helping people, it's rewarding in of-itself. But going for long periods
without releasing new projects is demotivating.
That's why I was excited for the start of July. I had an entire week free in Sydney to focus on shipping.
With this excitement, I naively announced that I'd get something out every day on Twitter.
::tweet{#01675466770029879298}
::
The v2 release of Nuxt SEO Kit has been something bouncing around in my mind for many months, so I decided to focus on that.
It started off really fun with overwhelmingly positive feedback. I even received my first Pizza sponsorship.
An extra special shout out to Omar McPizza
::tweet{#01676178825603076096}
::
Each night I was staying up progressively later and later. By the Friday release at 7am, I was done.
I had burnt the candle at both ends and was ready to enjoy the weekend.
::tweet{#01677574499078930432}
::
I wasn't able to get Nuxt SEO Kit v2 out, but I was happy with what I had achieved.
A month of work finished in one week. Even though it was a grind, it was really fun, and I'm grateful to the community
for their support.
### Turning 29
On the 10th July, my girlfriend and I flew to Greece, starting the Europe leg of our travels.
This also happened to be one of my more unique birthdays. I spent the entire day in airplanes and airports, ending
with a premium sleep on the floor of Singapore airport.
Being 28 was a good time with many memories and accomplishments while still trying to find my feet in open-source.
I'm especially proud of joining team Nuxt, and I'm excited for the year ahead and have plenty of exciting projects to release
### Exploring Greece
We got to Athens just as they were in a heat wave, going from the Australian winter to 40 degree heat.
Our plan was to get to Istanbul, exploring what we could on the way, with an obligatory Greek island stop.
We had a lot of fun in Athens, especially at the archaeological sites and museums.
This was also our introduction to authentic Greek food, which has become our new favourite cuisine.
::prose-img{src="/july-greek-food" alt="Moussaka, greek salad and stuff peppers." no-margin}
::
After Athens, we travelled to the beautiful Greek island Naxos.
We really liked exploring Halki village, the Old Town and beaches.
::expand
:::div{.2xl:grid.hidden.grid-cols-3.gap-5.my-15}
::::prose-img{src="/july-naxos-2.png" alt="Temple of Apollo, Naxos" no-margin}
::::
::::prose-img{src="/july-naxos-4.png" alt="Old Byzantine Church surrounded by olive trees" no-margin}
::::
::::prose-img{src="/july-naxos-harlan" alt="Tiny passageways in the Old Town of Naxos" no-margin}
::::
:::
::
::div{.2xl:hidden}
:::prose-img{src="/july-naxos-2.png" alt="Temple of Apollo, Naxos"}
:::
:::prose-img{src="/july-naxos-4.png" alt="Old Byzantine Church surrounded by olive trees"}
:::
:::prose-img{src="/july-naxos-harlan" alt="Tiny passageways in the Old Town of Naxos"}
:::
::
After Naxos, we headed inland to Meteora, which is known for its stunning rocky outcrops and monasteries. It was quite impressive.
::expand
:::div{.md:grid.hidden.grid-cols-1.gap-8.my-15}
::::prose-img{src="/july-meteroa-2.png" alt="Meteora monastery" no-margin}
::::
:::
::
::div{.2xl:hidden}
:::prose-img{src="/july-meteroa-2.png" alt="Meteora monastery"}
:::
::
Struggling with the heat, we decided to chill in Thessanoliki for a few days. The food, bars and museums were top tier.
Our final stop in Greece was the charming town of Xanthi, where we spent time wandering the picturesque cobblestone streets.
## Other Small Wins
- **VueFes Japan**: I'll be speaking at [VueFes Japan](https://vuefes.jp/2023/) on the 28th October, would love to see you there!
- **Nuxt Scripts**: Started working on [Nuxt Script](https://github.com/nuxt/nuxt/discussions/22016) in collaboration with the Google Aurora team
- **Nuxt Link Checker**: Released [v2](https://github.com/harlan-zw/nuxt-link-checker/releases/tag/v2.0.0) with Live Inspections.
## August Plan
- Release Nuxt SEO Kit v2 (hopefully)
- Nuxt Scripts private beta
- 🚌 Türkiye, Bulgaria, North Macedonia, Albania, Montenegro
---
- **Page:** April 2023: Thai New Year and Nuxt SEO Progress
- **Source:** https://harlanzw.com/blog/2023-april
- **Description:** April was spent traveling within Thailand while working through a bunch of bugs in my Nuxt SEO modules.
## Personal Updates
### A Month In Thailand
We spent all of April in Thailand, 38 days total.
We got lucky to sneak into Thailand just before they removed the 45-day visa on arrival.
As you'll know from the [last update](/blog/2023-march), we started the month in Ko Chang.
For those interested in our route afterward:
- Chanthaburi - A small and quiet town with charm, known for its gemstones.
- Pattaya - Caught up with some friends here to celebrate Thai New Year.
- Krabi - Amazing rock formations. Would highly recommend mangrove canoeing.
- Ko Phi Phi - Tiny scenic island but very touristy. Think bucket cocktails for $4.
- Khao Sok - Jungle, caves, lakes, monkeys and waterfalls. One of my favourite places in Thailand.
- Chiang Mai - Super-chill and relaxed city. Great food, great people, great vibes.
- Pai - Quite touristy with a hippy vibe. The surrounding nature is amazing.
::expand
:::div{.md:grid.grid-cols-2.gap-8.my-15}
::::prose-img{src="/april-mangrove.png" alt="Saw a monkey in the mangroves while kayaking" no-margin}
::::
::::prose-img{src="/april-koa-sok.png" alt="The jungles of Khao Sok" no-margin}
::::
::::prose-img{src="/april-phi-phi.png" alt="Ko Phi Phi" no-margin}
::::
::::prose-img{src="/april-pi.png" alt="In the Pai hole" no-margin}
::::
:::
::
### Thai New Year 🇹🇭
We were lucky enough to be in Thailand for Songkran.
Songkran is a festival to mark the start of the Buddhist New Year.
Water is thrown to symbolize the washing away of sins and bad luck.
We spent the holiday hanging out with our friends and the Thai locals,
throwing icy-cold water at passers-by on the street.
## Work Updates
### Nuxt SEO Kit v2 Module Progress
Getting v2 of Nuxt SEO Kit has been weighing on me for a while.
Making progress towards it meant solving some particularly annoying bugs.
Most of my time was spent on [nuxt-og-image](https://github.com/harlan-zw/nuxt-og-image).
It has been a challenging module.
Debugging WASM code, sifting through nitro code and testing on a number of cloud providers.
I went through 30 beta releases and things are looking better.
The rest went into [nuxt-simple-sitemap](https://github.com/harlan-zw/nuxt-simple-sitemap).
In March, I decided to add support for i18n.
I18n is complicated.
While there is still some more work to do it's a lot more stable and the improvements will be incremental.
## Numbers / Financials
- ⭐ 3779 GitHub stars (+199)
::prose-img{src="/april-numbers.png" alt="My WakaTime numbers for April"}
::
::prose-img{src="/april-fathom.png" alt="My April Fathom Traffic"}
::
### Open-Source
- ⌛ 70.75 hrs (-17.25 hrs)
- 💸 $1,013 AUD (+$226) - Had a payout from the Windi CSS project as it was sunset.
- $14.32 AUD / hour (+$5.38 / hour) - Highest yet! Although this was mostly a one-off.
**Main projects**
- **34 hours [nuxt-og-image](https://github.com/harlan-zw/nuxt-og-image)**
- **16.5 hours [nuxt-simple-sitemap](https://github.com/harlan-zw/nuxt-simple-sitemap)**
- **6 hours [Nuxt](https://github.com/nuxt/nuxt)**
### Freelancing
- ⌛ 2.25 hrs (-4.75 hrs)
- 💸 $225 AUD (-$420)
- $100 AUD / hour (-$7 / hour)
April's focus was on open-source work.
I had two organic leads come in.
I decided to push the meetings to May where I had more availability while traveling.
## May plans
In April, I wanted to get Nuxt SEO Kit v2 out. This was optimistic and unrealistic, unfortunately.
May's plan is to continue the work towards it.
The estimate for a Nuxt SEO Kit v2 stable release is still a few months away.
## Final thoughts
It was a really fun month in Thailand, I had a good balance of work and traveling.
It's never satisfying just working on bugs and issues, there's not much to show for it.
But this is the way open-source works it seems.
Periods of rapid development and then periods of slow but steady maintenance to get things stable.
Thanks for reading and thanks to my amazing sponsors.
---
- **Page:** Introduction: My open-source journey
- **Source:** https://harlanzw.com/blog/my-open-source-journey
- **Description:** Discover how you can build a modern package development through how I created the Unlighthouse project.
## My Open Source Journey
At the end of 2020, I finished up my full-time job at a [startup](https://kintell.com/){title="Visit kintell.com"}. I wanted to explore other ways of working,
with the idea of building a SaaS product.
Though, without an idea, I was passionate about and no audience. It felt like a waste of time.
Instead, I decided to focus on learning as much as I could within the Vue ecosystem.
I started building my [old blog](https://old.harlanzw.com){title="Visit my old blog site"}
in the earliest version of VitePress and did an article comparing [Vite to webpack](/blog/how-the-heck-does-vite-work){title="Visit the article I wrote about Vite"}.
In setting up Twitter and sharing that article, I was hooked.
I started writing more, contributing issues and building my own [Vue plugin](/blog/vue-automatic-component-imports){title="See the article I wrote on Vue Automatic Component Imports"}.
Through these I grew my audience and started focusing on [Nuxt modules](/projects) and joined the [WindiCSS](https://windicss.org) team.
Since then, I've been working on all sorts of projects. While progress has been slow while I've been learning, I'm proud of what I've accomplished,
and I'm excited to share more with the community.
---
- **Page:** May 2023: South Korea Mountains and Unlighthouse Goes Viral
- **Source:** https://harlanzw.com/blog/2023-may
- **Description:** May was spent traveling to South Korea and Indonesia while working on Unlighthosue going viral.
## Personal Updates
### South Korea Travel
In May, we finished up our time in Thailand and traveled to South Korea for 2 weeks.
- Seoul - Amazing city with so much to do. The culture is very unique and the mountains surrounding the city are beautiful.
- Busan - Coastal city, kind of reminded me of the Gold Coast in Australia.
- Jeju - Island off the south coast of South Korea. Beautiful beaches and hiking.
We really loved the food, especially the Korean BBQ and bipimbap.
They had surprisingly good coffee, craft beer and pastries, which I partook in generously.
Hiking Bukhansan Mountain in Seoul and Hallasan Mountain in Jeju were definitely the highlights.
I would love to return one day and explore more of the country.
::expand
:::div{.md:grid.hidden.grid-cols-2.gap-8.my-15}
::::prose-img{src="/may-jeju-coffee.png" alt="Open-source and coffee on Jeju" no-margin}
::::
::::prose-img{src="/may-black-pork.png" alt="Black pork and mungbean on Jeju" no-margin}
::::
::::prose-img{src="/may-bukhansan.png" alt="Top of Buhkansan Mountain" no-margin}
::::
::::prose-img{src="/may-seoul-exploring.png" alt="Exploring Seoul At Night" no-margin}
::::
:::
::
::div{.md:hidden.block}
:::prose-img{src="/may-jeju-coffee.png" alt="Open-source and coffee on Jeju"}
:::
:::prose-img{src="/may-black-pork.png" alt="Black pork and mungbean on Jeju"}
:::
:::prose-img{src="/may-bukhansan.png" alt="Top of Buhkansan Mountain"}
:::
:::prose-img{src="/may-seoul-exploring.png" alt="Exploring Seoul At Night"}
:::
::
### Indonesia Travel
After South Korea, we flew to Jakarta, Indonesia with a quick stop over in Singapore.
We'll spend the next month here, until mid-June when we head back to Australia.
We're taking Indonesia pretty slow.
It's nice to have a bit of a break after the busy itinerary in South Korea and Thailand.
- Yogyakarta - University town, lots of culture, art and history. Hostels here have a really nice, social vibe.
- Bali - Ubud and Canggu.
Canggu is very touristy, but the food is worth it.
Melbourne Cafe tier.
Ubud is quite touristy too, but you can see some
amazing sights around like rice fields, temples and waterfalls.
- Gili Islands - Good snorkeling and chilling. (currently here)
- Flores - Planning lots of hikes for here. We're looking forward to Komodo National Park and Kelimutu volcano.
::expand
:::div{.md:grid.hidden.grid-cols-2.gap-8.my-15}
::::prose-img{src="/may-indo-temple" alt="Borobudur temple in Yogyakarta" no-margin}
::::
::::prose-img{src="/may-sate.png" alt="Top tier sate" no-margin}
::::
::::prose-img{src="/may-jungle.png" alt="Jungles around Sideman" no-margin}
::::
::::prose-img{src="/may-cave.png" alt="Goa Gajah temple" no-margin}
::::
:::
::
::div{.md:hidden.block}
:::prose-img{src="/may-indo-temple" alt="Borobudur temple in Yogyakarta"}
:::
:::prose-img{src="/may-sate.png" alt="Top tier sate"}
:::
:::prose-img{src="/may-jungle.png" alt="Jungles around Sideman"}
:::
:::prose-img{src="/may-cave.png" alt="Goa Gajah temple"}
:::
::
## Work Updates
### Unlighthouse Goes Viral
Last month I had a DM from someone telling me that they had put Unlighthouse front of Jeff from Fireship.
I thought that was pretty cool in of itself, and I didn't think much more about it.
Next thing I knew, though, the Unlighthouse stars started going bananas.
::prose-img{src="/may-unlighthouse-stars.png" alt="Unlighthouse stars going bananas"}
::
I did some quick research and found the culprit.
Very cool.
And with that, I had a massive influx of issues to deal with through GitHub and my Discord.
::prose-img{src="/may-unlighthouse-issues.png" alt="Unlighthouse issues also going bananas"}
::
I started working through them, I would solve one, and two more would appear.
It took me the rest of my May to get control of them.
It wasn't a bad problem to have though.
I'm grateful to have Unlighthouse being used by so many people, and I'm glad it's helping people improve their sites.
It's given me a lot of ideas for how to improve it, and I'm excited to work on it more in the future.
## Numbers / Financials
- ⭐ 5407 GitHub stars (+1628)
::prose-img{src="/may-wakatime.png" alt="My WakaTime numbers for May 2023"}
::
::prose-img{src="/may-fathom.png" alt="My May 2023 Fathom Traffic"}
::
### Open-Source
- ⌛ 84 hrs (+13.25 hrs)
- 💸 $744 AUD (-$269) - Payout from Windi CSS the month before.
- $8.85 AUD / hour (-$5.46 / hour)
**Main projects**
- **27 hours [Unlighthouse](https://github.com/harlan-zw/unlighthouse)**
Some important improvements around reporting, authentication, how the chrome binary is used and Docker.
- **23 hours [nuxt-seo-kit](https://github.com/nuxt/nuxt)**
Extracting the breadcrumbs and site config logic into separate modules in preparation of v2. These will be released soon.
- **8 hours [nuxt-og-image](https://github.com/harlan-zw/nuxt-og-image)**
Getting the final issues solved so the v2 release can happen.
- **7.5 hours [nuxt-simple-sitemap](https://github.com/harlan-zw/nuxt-simple-sitemap)**
Some outstanding issues around runtime sitemaps and i18n.
### Freelancing
- ⌛ 8.25 hrs (+0.75 hrs)
- 💸 $1,025 AUD (+$800)
- $124.24 AUD / hour (+24.24 / hour)
I started some SEO work for a new client.
The work was to fix site-wide technical issues from Google Lighthouse.
This is exactly why I originally built Unlighthouse,
so it was perfect.
## June plans
I'll be landing back in Australia toward the middle of June.
I look forward to having a few weeks of routine.
I'll use this time to focus on getting Nuxt SEO Kit v2 released.
## Final thoughts
I'm really grateful to see the growth in Unlighthouse this month,
and I'm excited to finally release what I've been working on the for the last few months.
Thanks as always to my amazing sponsors.
See you next month.
.
---
- **Page:** June 2023: Flores, Australia and preparing for major releases
- **Source:** https://harlanzw.com/blog/2023-june
- **Description:** Traveling to Flores, Indonesia and coming back home to Australia while preparing for major releases.
## Personal Updates
### Flores
We spent the last week of our South East Asia trip in Flores, an island in Indonesia, east of Bali.
Flores is best known for its Komodo dragons and the glowing lakes of Mt Kelimutu volcano.
::prose-img{src="/june-flores-volcano.png" alt="Volcano coming in hot." no-margin}
::
We did a road trip from East (Maumere) to West (Labuan Bajo), weaving along "spaghetti roads" through the mountains and
dense jungle.
We passed tiny villages as we went that offered a unique glimpse into the life of the Floresian and the Manggarai people.
Nearly everyone lived off the land, with rice being grown as the main source of food.
Occasionally we'd see someone roasting their coffee beans which I also had a go at.
::prose-img{src="/june-cooling-beans.png" alt="Drying coffee beans post roast." no-margin}
::
When inspecting the jungle, we'd always see something growing:
mangoes, bananas, passion-fruit, cocoa, vanilla pods, taro, etc.
Our highlights were:
- Kelimutu volcano, which has 3 crater lakes at the top. These lakes change colour over time, and we were lucky enough to see them in their blue, green and black state.
- Rinca Island, seeing the Komodo dragons in their natural habitat.
- Bena Traditional Village, walking amongst amazing thatched roof houses.
::expand
:::div{.md:grid.hidden.grid-cols-2.gap-8.my-15}
::::prose-img{src="/june-flores-island.png" alt="The view of Padar Island." no-margin}
::::
::::prose-img{src="/june-flores-jungle.png" alt="Random spot we passed on the road trip." no-margin}
::::
::::prose-img{src="/june-kelimutu-2.png" alt="Kelimutu volcano at sunrise." no-margin}
::::
::::prose-img{src="/june-flores-komoto.png" alt="A Komoto dragon cooling off in the shade." no-margin}
::::
:::
::
::div{.md:hidden.block}
:::prose-img{src="/june-flores-island.png" alt="The view of Padar Island." no-margin}
:::
:::prose-img{src="/june-flores-jungle.png" alt="Random spot we passed on the road trip." no-margin}
:::
:::prose-img{src="/june-kelimutu-2.png" alt="Kelimutu volcano at sunrise." no-margin}
:::
:::prose-img{src="/june-flores-komoto.png" alt="A Komoto dragon cooling off in the shade." no-margin}
:::
::
### Australia: A wedding and camping
After Flores, we flew back to Australia for a wedding.
It's winter time here, so it was a bit of a shock to the system after 3 months of high heat and humidity.
After the wedding, we took a trip to the Warrumbungles with some friends.
::expand
:::div{.md:grid.hidden.grid-cols-5.gap-8.my-15}
::::prose-img
```yaml [props]
src: /june-warrumbungles.png
alt: View of the Warrumbungles.
no-margin: true
figure-class: col-span-3 span-3
```
::::
::::prose-img
```yaml [props]
src: /june-knife.png
alt: Breadknife and high tops hike.
figure-class: col-span-2 span-2
no-margin: true
```
::::
:::
::
::div{.md:hidden.block}
:::prose-img
```yaml [props]
src: /june-warrumbungles.png
alt: View of the Warrumbungles.
no-margin: true
figure-class: col-span-3 span-3
```
:::
:::prose-img
```yaml [props]
src: /june-knife.png
alt: Breadknife and high tops hike.
figure-class: col-span-2 span-2
no-margin: true
```
:::
::
The Warrumbungles is a national park north-east of Sydney which is best known for its amazing night sky views.
We camped there for a few days, doing some hikes and exploring the area.
Afterwards we spent time in the Blue Mountains and Sydney catching up with family and friends,
and planning the Europe leg of our trip.
::expand
:::div{.md:grid.hidden.grid-cols-2.gap-8.my-15}
::::prose-img
```yaml [props]
src: /june-blue-mountains.png
alt: Blue Mountains lookout swimming in fog.
no-margin: true
figure-class: col-span-2 span-2
```
::::
::::prose-img{src="/june-blue-mountains-hike.png" alt="Grand Canyon walk in Blackheath, Blue Mountains." no-margin}
::::
::::prose-img{src="/june-blue-mountains-hike-2.png" alt="More grand views." no-margin}
::::
:::
::
::div{.md:hidden.block}
:::prose-img
```yaml [props]
src: /june-blue-mountains.png
alt: Blue Mountains lookout swimming in fog.
no-margin: true
figure-class: col-span-2 span-2
```
:::
:::prose-img{src="/june-blue-mountains-hike.png" alt="Grand Canyon walk in Blackheath, Blue Mountains." no-margin}
:::
:::prose-img{src="/june-blue-mountains-hike-2.png" alt="More grand views." no-margin}
:::
::
## Work Updates
June was an important month of work,
getting things ready for the next major releases of my Nuxt modules.
The main blocker was Nuxt OG Image which had continued to plague me with issues.
With some luck and *many* hours of work, I was able to get it to a state where it's mostly working correctly on all
major runtimes and was able to unblock myself.
This was a huge relief, and I'm looking forward to pushing out all Nuxt SEO modules in the coming months.
## Numbers / Financials
- ⭐ 6112 GitHub stars (+705)
::prose-img{src="/june-wakatime.png" alt="My WakaTime numbers for June 2023"}
::
::prose-img{src="/june-fathom.png" alt="My June 2023 Fathom Traffic"}
::
### Open-Source
- ⌛ 87 hrs (+3 hrs)
- 💸 $867 AUD (+$123)
- $10.36 AUD / hour (+$1.51 / hour)
**Main projects**
- **Nuxt Modules Audit** (13.5 hours)
Some preliminary work to audit the state of Nuxt modules and authors. Still need some time to finish this off.
- **Nuxt SEO Modules** (45 hours)
Fixing outstanding issues to unblock work on the new majors deveolopment. Mostly involved
Nuxt OG Image, Nuxt Simple Sitemap and Nuxt Simple Robots.
Work also going into the initial versions of the new Nuxt Site Config module.
- **6 hours [Unlighthouse](https://github.com/harlan-zw/unlighthouse)**
Adding new CSV reporting feature and fixing some minor bugs.
### Freelancing
- ⌛ 10.25 hrs (+1.5 hrs)
- 💸 $1,025 AUD
- $100.00 AUD / hour (-24.24 / hour)
Small bit of adhoc bug fixing work for one of my clients. Fun with AWS SNS notifications...
## Final thoughts
It's really nice ce to be back in Australia and to have some more time to dedicate to work.
There are exciting things planned for July, some have already been released, and there's much more to come.
Thanks as always to my amazing sponsors.
See you next month!
---
- **Page:** February 2023: GitHub Sponsor perks and firing a freelance client
- **Source:** https://harlanzw.com/blog/2023-february
- **Description:** I share my updates for February covering my open-source development, financials and personal updates.
Hello, my amazing sponsors! Welcome to first ever newsletter. I have some exciting things to share with you.
## GitHub Sponsors Perk Updates
Going forward, I'll be improving your sponsor perks in three ways.
**Early Access Monthly Newsletter**
At the start of each month, I'll be sending a newsletter (just like this one).
This will cover my achievements, financials, and plans for the month ahead.
I'll post these publicly towards the end of the month on my blog.
**New Discord Role / Channels**
I have a Discord server that I use to provide support to many of my open-source packages. If you have any technical issues you'd like help on, feel free to ask here.
I have the following new perks within discord:
- New role: "sponsor"
- New discussion channel: 💎・priority-support.
Link to join:
**Bi-monthly Video Call**
Every 2 months, I'm going to schedule a time to do a video call with any sponsors who would like to join. In it, you can ask me any questions or just hang out, no pressure smile
- 30-60 minutes
- Held in discord (link above)
- First call is scheduled for 9pm AEDT, calendar invite link
## Month Review
### Achievements
*My personal, open-source and freelancing achievements.*
**OSS**
- ⭐ 3431 GitHub stars (+231)
- Unhead v1.1 release, many performance and security improvements
- Nuxt OG Image Satori Support and OG Image Playground
**Personal**
- 🍴 I started the month with a 120-hour fast (5 days)! Went pretty well, and lost 4kg
- ✈️ Gave notice to move out of our apartment at the end of March
**Sponsorship Impressions (sites only)**
The analytics of people who see your generous sponsorship on my doc / personal sites.
👨 5.7k visitors (-22)
👀 26.3k views (+4k)
### Financials
How sustainable is my current open-source work
My financial goal for February was saving $10k, as I have some travel plans coming up. I planned to spend 50% of my time on open-source.
I had a \~100-hour project booked with one of my clients. Around 40 hours into the project it was clear that I couldn't work with them anymore on it. I decided to fire them and ended up refunding them 40 hours of work to be done with it.
While this was quite a hit, the peace of mind of not having to work with them anymore on it was worth it. I'll avoid any bad-mouthing, I'll just say that they weren't pleasant to work with.
**OSS**
- ⌛ 111 hrs
- 💸 A$617.06
- A$5.55 / hour
**Freelancing**
- ⌛ 78.25 hrs (+ 35.25 hrs)
- 💸 A$3015 (- $517.5)
- A$38.53 / hour (-$42.67)
### March plans
By the end of this month, I'll be starting my travels for 3 months in Asia, going from Thailand, South Korea, and Indonesia.
During this time, I'll continue to work on open-source, just while sitting on a beach drinking a coconut.
Until then I have some goals:
- Significantly improve the Unhead performance in Nuxt (feat: server/client only composables, feat(head): improved Unhead integration)
- Docs site for Nuxt SEO Kit
- Make Nuxt OG Image an official Nuxt module, have a few bugs to fix
- Land a few more Nuxt PRs
### Summary
I hope you've enjoyed the first newsletter! Again, please join me for the first sponsors call on the 7th if you're interested.
Best wishes to all of you and I hope you have an amazing month ahead.
---
- **Page:** March 2023: Planning a year of travel and moving out
- **Source:** https://harlanzw.com/blog/2023-march
- **Description:** I share my updates for March covering my open-source development, financials and personal updates.
## Personal Updates
### Moving out
Since becoming a freelancer and focusing on open-source, my days are mostly relaxed. I can choose what I want to work
on and actively avoid any hard deadlines.
March was the complete opposite.
Deadlines on moving out, on planning and preparing for a year of travel, finishing off freelancing commitments
and staying on top of my open-source work.
But it all got done.
As of the end of March, my girlfriend (Alina) and I handed over the keys to our leafy-green apartment in Canberra, Australia.
With no where in particular to now call home.
### Travel
We started our travel last week, landing in Bangkok, Thailand. Bangkok is a hectic city, not recommended for relaxing.
But I enjoyed the city, the food and the people.
It has been interesting trying to work on open-source while travelling. Any down-time I have I whip out the laptop.
Thailand's internet has been surprisingly good which as made that easier.
As of writing this we're in Ko Chang, Thailand. Ko Chang is a dense mountainous jungle island with deep-blue water paired with complex orange gradient sunsets.
We're staying in a bungalow near the beach for $30 AUD a night.
## Numbers
- ⭐ 3580 GitHub stars (+149)
## Work Updates / Financials
With the cost of living in Thailand much cheaper than Australia, I'm spending less money while travelling than I would be
if I was still living in Canberra.
### Open-Source
- ⌛ 88 hrs (-23 hrs)
- 💸 $787 AUD (+$168)
- $8.94 AUD / hour (+$3.39 / hour)
Most of my open-source time last month went into Nuxt modules and Nuxt itself.
My current focus is trying to get all the Nuxt SEO Kit modules to a v2 and release v2 of Nuxt SEO Kit.
**Main projects**
- **24 hours [Nuxt](https://github.com/nuxt/nuxt)** - working on better Unhead integration, a couple of minor bugs and triage
- **22 hours [nuxt-simple-sitemap](https://github.com/harlan-zw/nuxt-simple-sitemap)** - v2 with many SSR improvements and bug fixes. Is now closer to being feature-complete.
- **14 hours [Unhead](https://github.com/unjs/unhead)** - working towards a v2 which will significantly reduce the bundle size.
- **12 hours [nuxt-og-image](https://github.com/harlan-zw/nuxt-og-image)** - v2 is out under a beta tag, with many bug fixes and improvements. Still want to add a couple of features for the v2 release.
- **3 hours [nuxt-simple-robots](https://github.com/harlan-zw/nuxt-simple-robots)** - v2 is out and now feature-complete.
### Freelancing
- ⌛ 6 hrs (-72.25 hrs)
- 💸 $645 AUD (-$2370)
- $107 AUD / hour (+$68 / hour)
Didn't have too much freelancing to do. Finished off some important projects and now have a bit of a break.
## April plans
Figuring out what I can actually finish while travelling is a challenge. I'm trying to keep my goals small and achievable.
I would like to get Nuxt SEO Kit v2 out by the end of April. This will be quite a bit of work as each dependent module
needs to be updated to v2, which I have many plans for. It also includes a proper documentation site.
I would also like to get the Unhead v2 update out. The bundle size improvements will be a win for Nuxt and the Vue ecosystem.
## Final thoughts
I'm really enjoying travelling and working while travelling. I hope to sustain it as long as I can and I can't thank you,
the sponsors, enough for your support.
While the amount I can get done will take a hit, I'm still dedicated to providing as much value
as I can for your contributions.
Please get in touch with my if you have any questions, feedback or suggestions. Would love to chat!
---
- **Page:** Nuxt 3 "Hydration Mismatch" Errors
- **Source:** https://harlanzw.com/blog/nuxt3-hydration-node-mismatch
- **Description:** Learn about the causes and solutions for the Hydration Children Mismatch error in Nuxt 3, including the use of client-only components, the component, and the @nuxtjs/html-validator module.
## The "Hydration Mismatch" Error
A hydration mismatch error is a disagreement between the server and the client on what the Vue rendered HTML should look like. When it
occurs when you see one of the following errors in your browser console:
```bash
[Vue Warn]: Hydration node mismatch...
[Vue Warn]: Hydration children mismatch...
```
### How Server-Side Rendering Works
When a user requests your Nuxt 3 site, Nuxt has one important job: Responding with the full rendered HTML of the page and all client-side scripts needed to bring it to life.
It does this by getting Vue to run its server-side rendering (SSR) code. This code is responsible for taking your Vue components and turning them into HTML.
When Vue renders the HTML, it is rendering DOM nodes in a way that may not match how a browser itself would render them. For example, it's quite possible to generate invalid HTML
with Vue whereas the browser would complain.
### How Client-Side Hydration Works
When the user's browser receives the HTML, it loads the client-side scripts. These scripts are responsible for bringing initializing your Vue app,
rendering all of your components with the goal of producing the same HTML that the server rendered.
In doing so, it has the full state needed to create new interactivity.
### Consequences of a Hydration Mismatch
When a hydration mismatch occurs, the client-side Vue app can have quite unpredictable results. Leading to interactivity not working,
or even worse, the page not working at all.
Most commonly, this error will lead to a poor user experience and SEO issues.
## Solving the Hydration Mismatch Error
There are a number of ways to solve this error.
The first step is to identify the cause of the mismatch. This can be quite tricky in Nuxt 3 as the error messages can sometimes
be quite cryptic.
### Ensure Pages Have a Single Root Element
As a quick check, if your error is pointing to a page component, it's possible that the error is from multiple root elements.
While not technically required in Vue 3, it's required in Nuxt 3 to have a root element in your page components.
**Bad - Has 2 root elements**
```vue
My Page
Hello
```
**Good - 1 root element**
```vue
My Page
Hello
```
### Check the HTML
Because the trigger for the error is the HTML not matching, this can be a good starting point to debugging.
There are many ways to produce invalid HTML in Vue. It's not always intuitive to know these what is invalid though.
The easiest way to check the HTML is to use the [@nuxtjs/html-validator](https://html-validator.nuxtjs.org/) module. This module will validate the HTML and
throw an error if it is invalid.
### Use Client Only Components
If you notice that the error is coming from a component that is only needed on the client-side, you can wrap it with `` component, alternatively.
you can prefix your component name with `.client.vue`. For example `MyWidget.client.vue`.
This is quite common components which use code coming from a third-party library. For example, a component which uses a Google Maps library.
In these instances, the third-party library does not have support to server-side render.
```vue
```
## Conclusion
Hydration mismatch errors can be quite frustrating and tricky to debug. Having a better understanding of the root cause of the error
and how to solve it can help you avoid these issues in the future.
---
- **Page:** Learning Modern Package Development: Monorepos and Backends - Part 1
- **Source:** https://harlanzw.com/blog/modern-package-development
- **Description:** Discover how you can build a modern package development through how I created the Unlighthouse project.
::tip
This article is a work in progress. Feel free to read it, but some sections are incomplete.
::
## Introduction
[Unlighthouse](https://github.com/harlan-zw/unlighthouse) is an open-source package I built to scan your entire site using Google Lighthouse.
Building it was chaotic, with day-long bugs, constant refactoring and endless documentation reading.
Through building it, I learnt modern development practices, making use of the vast ecosystem of packages and tools.
## Background: Why build Unlighthouse?
As a freelancer I keep on top of my clients organic growth with Google Search Console.
Was a day like any other, looking at one of my clients' dashboard. Seemingly out of nowhere, I saw the trend of page position, clicks and page views in free fall. My clients' income was based on organic traffic, not good.
{height="416" width="1552" max-height="242"}
Isolating the reason for the falling page rank wasn't easy. The site had issues, but what was causing the free fall? There was no easy way to know.
To diagnose the issue, I used Google Lighthouse. I went through all pages of the site, noticing quite a number of issues. I spent a couple of days
fixing them all up and improving the general performance of the site.
What happened next? Things started turning around. I was able to invert the graph. Organic growth doubled in the next few months. Happy client.
{height="399" width="1554" max-height="242"}
Now that was out of the way, how could I make it easier to stay on top of the health of the sites I manage?
## Deciding on the stack
I needed to build a tool that would run Google Lighthouse on an entire site with just the home page URL.
I had a plan of attack for the build.
The backend would be build using Typescript and Node.
The frontend client would be built using Vue and Vite.
But how would I be able to design this in a way that was easy to build and maintain?
I had seen the amazing work coming out of the [UnJS](https://github.com/unjs) ecosystem and knew that they could solve some of my problems.
With that, the package would be known as **Un** (inspired by Unjs) **Lighthouse**.
Keeping a keen eye on other modern packages coming out, I took some of the best practices and tools I saw implemented.
The stack was split into three core parts:
- Monorepo: containing the dependencies to build, test and deploy the code
- Frontend: displaying searchable, filtering and sortable results
- Backend: generating the frontend, running the scans and providing an API for the frontend
## Monorepo
Implementing a monorepo is a keystone for a large project which will ship multiple packages. It allows you to
group up logic, dependencies and documentation into a single repository.
### PNPM
[PNPM](https://pnpm.io/) is the new kid on the block of node package managers and has gained a large following quickly, for good reason. It is the most performant package manager and has first class support for monorepos.
There are many benefits to using a monorepo for a package. My personal favourite is it allows me to easily isolate logic and dependencies for your package, letting you write simpler code. Allowing end users to pull any specific part of your package that they want to use.
{height="507" width="942" max-height="400"}
### Vitest
[Vitest](https://vitest.dev/) is also the new kid on the block of testing. It's original aim was to be a testing framework specifically for Vite, but it has ended up being a possible replacement for Jest entirely.
Vitest makes writing your logic and tests a breeze and I'd recommend checking it out for any project.
### Unbuild
{height="350"}
This package is described as a "A unified javascript build system".
In reality, it's a minimal config way to build your package code to ESM and CJS.
One of the amazing features of unbuild is stubbing. This allows you can run source code from your dist folder, meaning it transpiles just-in-time.
This allows you to completely cut out the build step when you're iterating and testing integrations on your package.
It's as simple as `unbuild --stub`.
```ts [build.config.ts]
import { defineBuildConfig } from 'unbuild'
export default defineBuildConfig({
entries: [
{ input: 'src/index' },
{ input: 'src/process', outDir: 'dist/process', builder: 'mkdist', declaration: false },
],
})
```
[GitHub Repo Link](https://github.com/unjs/unbuild)
## Server
### Lighthouse Binary
Unlighthouse wouldn't be possible if Google hadn't published Lighthouse as its own [NPM binary](https://github.com/GoogleChrome/lighthouse).
To make Unlighthouse fast, I combined the binary with the package [puppeteer-cluster](https://github.com/thomasdondorf/puppeteer-cluster), which allows for multi-threaded lighthouse scans.
### Unctx
{height="350"}
It's amazing that a simple pattern like composition has evaded Node packages for so long.
With the introduction of Vue 3, composition became cool. And with that, unctx is composition for your own package.
unctx allows you to define a scope where there's only a single instance of something that is globally accessible. This is incredibly useful for building packages, as you no longer need to be juggling core state. You can build your logic out as composables that interact with the core.
```ts
import { createContext } from 'unctx'
const engineContext = createContext()
export const useUnlighthouse = engineContext.use as () => UnlighthouseContext
export async function createUnlighthouse(userConfig: UserConfig, provider?: Provider) {
// ...
engineContext.set(ctx, true)
}
```
- [unctx GitHub Repo](https://github.com/unjs/unctx)
### Hookable
{height="350"}
For Nuxt.js users, you might be familiar with the concept of frameworks hooks. A way for you to modify or do something with the internal logic of Nuxt.
Building a package, I knew that this was a useful feature, not just for end-users, but for me as a way to organise logic.
Having a core which is hookable means you can avoid baking logic in that may be better suited elsewhere.
For example, I wanted to make sure that Unlighthouse didn't start for integrations until they visited the page.
I simply set a hook for it to start only when they visit the client.
```ts
hooks.hookOnce('visited-client', () => {
ctx.start()
})
```
- [Hookable GitHub Repo](https://github.com/unjs/hookable)
### Unconfig
{height="350"}
Unconfig is a universal solution for loading configurations. This let me allow the package to load in a configuration from `unlighthouse.config.ts` or a custom path, with barely any code.
```ts
import { loadConfig } from 'unconfig'
const configDefinition = await loadConfig({
cwd: userConfig.root,
sources: [
{
files: [
'unlighthouse.config',
// may provide the config file as an argument
...(userConfig.configFile ? [userConfig.configFile] : []),
],
// default extensions
extensions: ['ts', 'js'],
},
],
})
if (configDefinition.sources?.[0]) {
configFile = configDefinition.sources[0]
userConfig = defu(configDefinition.config, userConfig)
}
```
- [Unconfig GitHub Repo](https://github.com/antfu/unconfig)
### ufo
{height="350"}
Dealing with URLs in Node isn't nice. For Unlighthouse I needed to deal with many URLS, I needed to make sure they were standardised no matter how they were formed.
This meant using the ufo package heavily. The slash trimming came in handy and the origin detection.
```ts
export const trimSlashes = (s: string) => withoutLeadingSlash(withoutTrailingSlash(s))
```
```ts
const site = new $URL(url).origin
```
- [ufo GitHub Repo](https://github.com/unjs/ufo)
### Unrouted
{height="350"}
I needed an API for the client to communicate with the Node server to fetch the status of the scan and submit re-scans.
The current JS offerings were a bit lackluster. I wanted something that just worked and had a nice way to use it.
I ended up building unrouted as a way to solve that.
```ts
group('/api', () => {
group('/reports', () => {
post('/rescan', () => {
// ...
return true
})
post('/:id/rescan', () => {
const report = useReport()
const { worker } = useUnlighthouse()
if (report)
worker.requeueReport(report)
})
})
get('__launch', () => {
const { file } = useQuery<{ file: string }>()
if (!file) {
setStatusCode(400)
return false
}
const path = file.replace(resolvedConfig.root, '')
const resolved = join(resolvedConfig.root, path)
logger.info(`Launching file in editor: \`${path}\``)
launch(resolved)
})
get('ws', req => ws.serve(req))
get('reports', () => {
const { worker } = useUnlighthouse()
return worker.reports().filter(r => r.tasks.inspectHtmlTask === 'completed')
})
get('scan-meta', () => createScanMeta())
})
```
- [Unrouted GitHub Repo](https://github.com/harlan-zw/unrouted)
## Client
The code that what went into building the package.
### Vue 3 / Vite client
The beloved [Vite](https://github.com/vitejs/vite) was to be used to make the development of the client as easy and fast as possible.
Vue v3 used to make use of the vast collection of utilities available at [VueUse](https://vueuse.org/).
## Putting It Together - Part 2
Part 2 of this article will be coming soon where I go over some technical feats in putting together the above packages.
## Conclusion
Thanks for reading Part 1. I hope you at least found it interesting or some of the links useful.
---
- **Page:** VueUse Head v1 release
- **Source:** https://harlanzw.com/blog/vue-use-head-v1
- **Description:** Learn about the new @vueuse/head v1 release including new features and breaking changes
This post aims to provide some insight into the v1 release, what's new and what's changed.
If you prefer, feel free to jump straight to the [release notes](#v100-release).
## Taking over as maintainer
A few months ago I was decided to fix a quick bug on [@vueuse/head](https://github.com/vueuse/head). I fixed that bug and decided to fix
another.
At the time there was no one actively maintaining the package, besides [antfu](https://twitter.com/antfu7), kindly reviewing any PRs which
got sent. [EGOIST](https://github.com/sponsors/egoist) did an awesome job on it initially, but he was now busy with other projects.
I decided that taking over as maintainer would be a good way to contribute more directly to the Vue ecosystem. A quick
discussion with Anthony later and I was given the role.
Taking over maintenance my goal was to close all open issues, improve performance, documentation and overall developer experience. All
I hope have now been achieved with this release.
::tip{type="info"}
The v1 release is now shipped in [Nuxt](https://nuxtjs.org).
I'll be doing a talk on this release at the upcoming [NuxtNation](https://nuxtnation.com/), make sure you catch it!
::
## Pre v1 Achievements
Taking over maintenance was pretty daunting. There were a number of tricky problems to solve.
I started with some low-hanging fruit: `useHead` TypeScript support ([6f919c7](https://github.com/vueuse/head/pull/85)).
This one was pretty tedious, but well worth it for developer experience. In creating the types, I made the [zhead](https://github.com/harlan-zw/zhead)
package to share the types with the ecosystem and some other small utils.
Next up was computed getter support ([b6d74dbeb](https://github.com/vueuse/head/commit/b6d74dbebd32f772923f722c399091c73e21b6ed)).
With VueUse 9, the recommended way to deal with computed data was with a computed getter, which is simply a function. However,
`useHead` only let you use `computed` or `ref`. This was a pretty easy fix, however it illuminated a major performance bottleneck.
So my full attention went into performance ([e1bc8d2](https://github.com/vueuse/head/commit/e1bc8d2e35a9104adbd3a62c29bdadb89181b3fc), [691bcc8](https://github.com/vueuse/head/commit/691bcc88ae3526879238995866daeec39e8bc4c6)).
🏎️ And with that the package was now \~5x faster in patching the DOM.
## Why a major bump?
While most issues were closed and easy to solve, there was a major outstanding issue that I wanted to address, *Server Only Tags* (removed, `nuxt/framework` discussions were retired after the Nuxt 3 merge into `nuxt/nuxt`).
There was also a nasty issue with tags disappearing unexpectedly when hydrating.
Both of these were blocked by how the DOM patching was designed. The old strategy was to add state to the DOM and use this
to determine what tags to remove.
```html
```
You can see in the above, we're tracking the attributes being added and the number of tags. The `head:count` element
is used as an anchor to start rendering tags from.
It would take all tags to be rendered, check the dom elements upwards from the `head:count` meta tag if any needed to be inserted
or if they already existed, otherwise delete them.
This had a issues when you look closely:
### Deleting DOM elements it doesn't own
It would modify whatever element is in the position regardless if it's an element created from `@vueuse/head`.
This led to issues with a third-party script inserting something in between these DOM elements, and it getting deleted.
It also blocked server only tags, as the client would overwrite them when hydrating.
### Performance
Previous DOM patching would delete and re-render tags which shared dedupe keys. Not noticeable functionally but a performance consideration.
To solve these issues, a new DOM patching algorithm was needed that tracked side effects gracefully.
### State in DOM isn't so nice
Purely from a DX perspective, it's not nice to have state in the DOM that isn't needed. It's not a big deal, but it's not ideal.
## Unhead
This was a major piece of work, and I figured if I'm going to have to refactor the entire package, I should aim to also solve
another outstanding issue, *Universal Head Management*.
So I started on [Unhead](https://github.com/harlan-zw/unhead), a Universal document tag manager.
My initial concern was just feature parity with `@vueuse/head` while supporting server tags, but I quickly realised that I could do better.
So Unhead was born. It has first-party support for Vue, but there is planned work to support any and all other frameworks (eventually).
@vueuse/head v1 is now a thin wrapper for unhead, while being fully backwards compatible.
There are too many enhancements and feature improvements to mention here, check the below release notes or jump straight
to the [docs](https://unhead.unjs.io).
I'm excited about this new package, and I hope you are too! I'll be writing a dedicated blog post about it soon.
## v1.0.0 Release
### 🤖 Core
Now powered by [unhead](https://github.com/harlan-zw/unhead).
Featuring:
- a new DOM patching algorithm that tracks side effects gracefully, less aggressive removal of tags and attributes
- ⚡ DOM rendering optimisations, 5x faster (\~10ms for an avg site), async for quicker initial main thread load.
- 🧹 No more non-essential state in DOM
### ✨ Enhancements
- Vue 2.7 Support ([docs](https://unhead.unjs.io))
- Options API Support ([docs](https://unhead.unjs.io))
#### htmlAttrs / bodyAttrs merging
[Documentation](https://unhead.unjs.io)
Now merged by default instead of replace.
```ts
useHead({
htmlAttrs: {
class: 'my-class',
},
})
// we don't want that class to be on a specific page, instead we want a new class
useHead({
htmlAttrs: {
class: 'another-class',
},
})
//
```
#### Array / Object Classes
[Documentation](https://unhead.unjs.io)
When using the htmlAttrs or bodyAttrs options, you can use the class attribute to add classes to the html or body elements.
```ts
const darkMode = false
useHead({
htmlAttrs: {
class: {
// will be rendered
dark: darkMode,
// will not be rendered
light: !darkMode,
}
},
bodyAttrs: { class: ['layout-id', 'page-id'] }
})
```
#### Better deduping
[Documentation](https://unhead.unjs.io)
Tag deduping is now vastly improved. It's likely you won't need `key` anymore.
Includes support for meta content array support, reduces boilerplate by using arrays for meta tags.
```ts
useHead({
meta: [
{
name: 'og:image',
content: [
'https://example.com/image.png',
'https://example.com/image2.png',
],
},
],
})
```
#### Prop promises
You can provide a promise to props and it will be resolved when rendering the tags.
```ts
useHead({
script: [
{
children: new Promise(resolve => resolve('console.log("hello world")'))
},
],
})
```
### 🚀 New Features
#### useServerHead
[Documentation](https://unhead.unjs.io)
Lets you render tags on the server only. This has the same API as `useHead`.
```ts
useServerHead({
scripts: [
{
// this wouldn't work on the client, so we use useServerHead
src: import('~/assets/my-script.js?url'),
}
]
})
```
#### useSeoMeta
[Documentation](https://unhead.unjs.io)
Define meta tags in a flat object, fully typed.
```ts
useSeoMeta({
description: 'My about page',
ogDescription: 'Still about my about page',
ogTitle: 'About',
ogImage: 'https://example.com/image.png',
twitterCard: 'summary_large_image',
})
```
#### tagPosition
[Documentation](https://unhead.unjs.io)
Lets you define the position of a tag in the DOM.
```ts
useHead({
script: [
{
src: 'https://example.com/script.js',
tagPosition: 'bodyOpen',
}
]
})
```
#### tagPriority
[Documentation](https://unhead.unjs.io)
Lets you define the priority of a tag with a number or string.
```ts
useHead({
script: [{ key: 'not-important', src: '/not-important-script.js', },],
})
useHead({
script: [
{
// script is the tag name to target, `not-important` is the key we're targeting
tagPriority: 'before:script:not-important',
src: '/must-be-first-script.js',
},
],
})
```
#### tagDuplicateStrategy
[Documentation](https://unhead.unjs.io)
#### DOM Event Handlers
[Documentation](https://unhead.unjs.io)
Function support for DOM event handlers.
```ts
useHead({
bodyAttrs: {
onresize: (e) => {
console.log('resized', e)
}
},
script: [
{
src: 'https://example.com/analytics.js',
onload: (el) => {
console.log('loaded', el)
}
}
]
})
```
#### Hooks
Engine is now powered by hooks, provided by [hookable](https://github.com/unjs/hookable). This allows you to hook into
any of the core functionality.
See [API hooks](https://unhead.unjs.io) and [Infer SEO MetaTags](https://unhead.unjs.io), not documented properly yet.
#### New shortcut composables
Same API as `useHead`, but targeted as a specific tag type.
- useTagTitle
- useTagBase
- useTagMeta
- useTagLink
- useTagScript
- useTagStyle
- useTagNoscript
- useHtmlAttrs
- useBodyAttrs
- useTitleTemplate
## Migration Guide
⚠️ Breaking changes are minimal, but there are some changes to be aware of.
Please report any issues you find and they will fixed be promptly.
You may consider using [@unhead/vue](https://unhead.unjs.io) directly if you [don't need @vueuse/head](https://unhead.unjs.io).
### Verify your tags
The new DOM patching algorithm has not been tested in all possible scenarios, it's possible that there are unforeseen edge cases.
### `htmlAttrs` and `bodyAttrs` merge strategy
If you had built your code around with the assumption that setting `htmlAttrs` or `bodyAttrs` would clear the old tags, this is now different.
```ts
// old
useHead({
htmlAttrs: {
class: 'my-class',
},
})
useHead({
htmlAttrs: {
class: 'new-class',
},
})
//
```
```ts
// new
useHead({
htmlAttrs: {
class: 'my-class',
},
})
useHead({
htmlAttrs: {
class: 'new-class',
},
})
//
```
Check the [documentation](https://unhead.unjs.io) to learn more.
### Duplicate tags in `useHead`
To make duplicate tag handling more intuitive, duplicate tags are now allowed in the same `useHead` call.
Previously you would have to use `key` to differentiate them.
```ts
// old - key is required
useHead({
meta: [
{ name: 'og:locale:alternate', content: 'es_ES', key: 'locale_es' },
{ name: 'og:locale:alternate', content: 'fr_FR', key: 'locale_fr' },
],
})
//
//
```
You can safely remove `key` now.
```ts
// new - key is no longer required
useHead({
meta: [
{ name: 'og:locale:alternate', content: 'es_ES' },
{ name: 'og:locale:alternate', content: 'fr_FR' },
],
})
//
//
```
It's worth checking that if you did have duplicate tags in the same entry, you will now be rendering both of them potentially.
## Next Steps
If you have any issues and/or questions related to v1, please comment in the [discussion](https://github.com/vueuse/head/discussions/161).
If you have any ideas on the future of unhead and @vueuse/head please get in touch with me through an issue, Discord or Twitter.
Thanks for reading!
---
- **Page:** Nuxt 3 Migration Simplified: A Cheat Sheet
- **Source:** https://harlanzw.com/blog/nuxt-3-migration-cheatsheet
- **Description:** A simplified cheat sheet for migrating from Nuxt 2 to Nuxt 3. Includes a list of all the changes you need to make to your Nuxt 2 project to get it working with Nuxt 3.
## Introduction
Upgrading to Nuxt 3 from Nuxt 2 can be a little intimidating.
There are a ton of changes to keep track of, and it's easy to feel like you're lost in a maze of code.
But don't worry, I'm here to help!
I've started this cheat sheet
to simplify the upgrade process for myself and for others who are brave enough to tackle it.
It's a work in progress, and not intended to be exhaustive. I'll be updating it as I migrate more sites.
Just remember that the migration process will take some time, patience, and persistence, depending on the size of your project and your prior experience.
But hey, at least you'll learn a bunch.
And if you're just looking to upgrade some modules, I've written an [upgrade guide](#2-modules) for 23 of them.
Let's do this!
## Resources
### Links
**Docs**
- [Nuxt 3 - Docs](https://nuxt.com/docs/getting-started/introduction)
- [Nuxt 3 - Official Migration Guide](https://nuxt.com/docs/migration/overview)
- Nuxt 3 - Migration guide discussion (removed, `nuxt/framework` discussions were retired after the Nuxt 3 merge into `nuxt/nuxt`)
- [Vue 3 - Migration Guide](https://v3-migration.vuejs.org/)
**Tech**
- [UnJS](https://unjs.io/) – A collection of packages which make up the Nuxt core.
- [VueUse](https://vueuse.org/) – Essential Vue Composition API utilities.
- [Vite.js](https://vitejs.dev) and [Rollup](https://rollupjs.org/) – Backbone of Nuxt 3 bundling.
**Nuxt 3 Examples**
- [Nuxt Examples](https://nuxt.com/docs/examples/essentials/hello-world) - Minimal scoped examples
- [NuxtBnB](https://web.archive.org/web/20230106223315/https://github.com/MasteringNuxt/NuxtBnB) - Complex app (repo removed; archived snapshot)
- [Nuxt Movies](https://github.com/nuxt/movies) - Complex app
- [harlanzw.com](https://github.com/harlan-zw/harlanzw.com) - @nuxt/content app
### Education
- Free: NuxtNation 2022 [YouTube playlist](https://www.youtube.com/playlist?list=PLxddmVXxb3Hu4yg6jEUBcQ6xix6QM0ews)
- Paid: [Mastering Nuxt](https://masteringnuxt.com/)
### Getting help
- [Discord](https://discord.com/invite/ps2h6QT): create a post in `#nuxt3-help` if you get stuck
- Twitter Nuxt Community (removed, the community no longer exists)
- Post on StackOverflow with the tag [Nuxt](https://stackoverflow.com/questions/tagged/nuxt), [kissu](https://stackoverflow.com/users/8816585/kissu) may help you if you're lucky
- [Reddit Nuxt.js](https://www.reddit.com/r/nuxtjs/)
::tip
Need quicker help? Try providing a [reproduction repo](https://nuxt.com/docs/community/reporting-bugs#create-a-minimal-reproduction).
At a minimal, provide the output of `npx nuxi info`.
::
## Pre-migration
Before you start upgrading, it's important to familiarise yourself with the new features and changes in Nuxt 3 and prepare your
project for the upgrade.
### 1. Get familiar with Nuxt 3
Learn the key differences between Nuxt 2 and Nuxt 3 and the new technologies that power it.
::checkbox
#default
Nuxt 3 Architecture
#tip
While there are many similarities to Nuxt 2, the underlying architecture is completely new, more complex and more powerful.
Start by learning the differences between:
- [Nuxt](https://nuxt.com/docs/guide/going-further/internals#the-nuxt-interface) (core context)
- [Nuxt App](https://nuxt.com/docs/guide/going-further/internals#the-nuxtapp-interface) (app context)
- [Nitro](https://nuxt.com/docs/guide/concepts/server-engine) (server engine)
- Nuxi (command line interface)
You may want to re-familiarise yourself with the [Rendering modes](https://nuxt.com/docs/guide/concepts/rendering) (SSR, SPA, Static).
Daniel Roe's talk [What happens when you start Nuxt 3](https://www.youtube.com/watch?v=IVA_76hKEwE) is a great place to start.
::
::checkbox
#default
Read the official migration guide
#tip
Having a rough understanding of the [migration process](https://nuxt.com/docs/migration/overview) will allow you
avoid common pitfalls.
Keep this open as you'll reference this as you continue on with the migration process.
::
::checkbox
#default
Play with Nuxt 3
#tip
Create a new fresh Nuxt 3 project using [nuxt.new](https://nuxt.new), play around with it.
This will help you get a feel for the new features and how they work.
::
::checkbox
#default
Learn Vue 3 / Composition API
#tip
Vue 3 is a major upgrade from Vue 2. You will need to learn the new syntax and features to be able to use Nuxt 3, particularly
the [Composition API](https://vuejs.org/guide/extras/composition-api-faq.html).
::
::checkbox
#default
Learn the basics of TypeScript
#tip
Whether you like TypeScript or not is irrelevant. Nuxt 3 is written in TypeScript, trying to avoid it will only make your life harder.
You don't need to be an expert, but you should be able to read and understand the Nuxt 3 code.
You can learn the basics with [TypeScript in 5 minutes](https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html).
::
::checkbox
#default
Learn about modules: CJS and ESM
#tip
Nuxt 3 requires ECMAScript modules, so you'll need to convert your CommonJS modules. See the Nuxt [ES modules](https://nuxt.com/docs/guide/concepts/esm) page.
Many of the issues you'll come across are due to using dependencies which are CJS.
So understanding the difference between them can be critical.
Node modules can be a challenging subject, if you need further guidance see [this article](https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/).
::
### 2. Prepare your Nuxt 2 app
Before you start migrating, you should prepare your Nuxt 2 app to make the upgrade process easier.
::checkbox
#default
Upgrade Nuxt 2
#tip
If you are on an earlier version, the first step is upgrading.
The latest Nuxt 2 uses [Vue 2.7](https://blog.vuejs.org/posts/vue-2-7-naruto.html), which supports the Composition API.
```bash
npm i nuxt@^2.15.4
# yarn add nuxt@^2.15.4
```
::
::checkbox
#default
Upgrade Modules
#tip
Upgrade your modules to the latest version that supports Nuxt 2. This will make the upgrade process easier.
Be careful not to upgrade modules which may only support Nuxt 3 in future versions.
::
::checkbox
#default
Enable component auto-imports
#tip
Nuxt 3 has auto-imports enabled by default, if you're not using this feature in Nuxt 2, you should enable it to identify
any component paths that need to be updated.
All manual component imports should be removed.
See [Components Discovery](https://nuxtjs.org/docs/directory-structure/components/#components-discovery) for more information.
```ts [nuxt.config.ts]
export default {
components: true
}
```
::
::checkbox
#default
Audit your dependencies
#tip
Having hard dependencies in your project that aren't compatible with Nuxt 3 / Vue 3 is going to massively slow you down.
You should go through all your node dependencies, checking the following:
- Vue plugin: Supports Vue 3
- Nuxt module: Supports Nuxt 3 or has an upgrade path. See [Migration - 2. modules](#2-modules) and the [Nuxt 3 modules](https://nuxt.com/modules).
- Other: Scan with [bundlephobia](https://bundlephobia.com/) to find ESM support / alternatives. You can use CJS dependencies, but you'll need to [transpile](https://nuxt.com/docs/guide/concepts/esm#transpiling-libraries) them.
This is a good opportunity
to drop any dependencies you don't need, migrate to dependencies which have better maintenance and / or are smaller.
::
## Migration
This cheat sheet requires a fresh Nuxt 3 project and migrating things over one by one.
In each step of migrating, you should test to see if everything is working.
If there are bugs, then you will be able to debug them easily.
### 1. Create the new app
The first step is creating the new apps boilerplate and migrate over safe config from `nuxt.config.ts`.
::checkbox
#default
Create a Fresh Nuxt 3 App
#tip
You can do so with `npx nuxi init my-app`.
This will create a new Nuxt 3 project.
::
::checkbox
#default
Create Boilerplate
#tip
By default, Nuxt 3 does not provide page routing and layouts. To make the migration easier, you'll be modifying the files to be Nuxt 2 compatible.
To enable them we update `app.vue` with `NuxtLayout` and `NuxtPage`.
```vue [app.vue]
```
Create an empty default layout file that will be updated later.
```vue [layouts/default.vue]
```
Create a basic index page, so we can see our changes.
```vue [pages/index.vue]
hello world
```
::
::checkbox
#default
Migrate Runtime Config / env
#tip
Follow the [Migrate Runtime Config](https://nuxt.com/docs/bridge/overview#update-runtime-config) doc.
The `env` key in `nuxt.config.ts` is no longer supported, you should use runtime config exclusively.
```ts
// Nuxt 2
export default {
privateRuntimeConfig: {
apiKey: process.env.NUXT_API_KEY || 'super-secret-key'
},
publicRuntimeConfig: {
websiteURL: 'https://public-data.com'
},
env: {
NUXT_API_KEY: process.env.NUXT_API_KEY
}
}
```
```ts
// Nuxt 3
export default defineNuxtConfig({
runtimeConfig: {
apiKey: process.env.NUXT_API_KEY || 'super-secret-key',
// Warning: `public` is exposed on client and server
public: {
websiteURL: 'https://public-data.com'
}
}
})
```
You can safely ignore TypeScript issues this causes for now.
::
::checkbox
#default
Migrate head
#tip
If you previously had head configuration in your `nuxt.config.ts`, you should migrate it to `app.head` config.
If you're using `hid` in your head config, unless you have a good reason, you should remove it. Read [@vueuse/head v1 release notes](/blog/vue-use-head-v1)
to learn more.
Note: Tags with `href` and `src` will not resolve relative links / aliases.
You will need to reference absolute links from the public directory. If you'd like to use aliases, you can use the [nuxt-unhead](https://github.com/harlan-zw/nuxt-unhead) module.
```ts
// nuxt 2
head: {
meta: [
{ hid: 'description', name: 'description', content: 'My custom description' },
]
}
```
```ts
// nuxt 3
app: {
head: {
meta: [
{ name: 'description', content: 'My custom description' }
]
}
}
```
::
::checkbox
#default
Copy Static files
#tip
In Nuxt 3 the `/static` folder has been renamed to `/public`.
Make the `public` directory and copy all of these files over.
::
::checkbox
#default
Copy CSS / Assets
#tip
In Nuxt 3, the assets folder serves the same function.
Simply copy over your assets' folder.
Once done, update the `css` field.
```ts [nuxt.config.ts]
css: [
'~/assets/main.css',
]
```
Most pre-processors will work with no extra config.
You will just need to install the dependencies. For example, if you see an error like:
```bash
Preprocessor dependency "sass" not found. Did you install it?
```
You will need to run `npm install -D sass`.
If you're having issues with linked images within CSS,
you may need to use absolute links to the images in the public directory.
```css
/* Nuxt 2 - we have the file: assets/images/logo.png */
body {
background-image: url('~/assets/images/logo.png');
}
```
```css
/* Nuxt 3 - we have the file: public/images/logo.png */
body {
background-image: url('/images/logo.png');
}
```
You may have some node_module dependencies in your css entries, install each of these as needed.
::
### 2. Modules
Module migration can be a challenging part of the migration process. You will need to migrate each module one by one and test your app after each module.
Nuxt 2 modules are not compatible with Nuxt 3 out of the box, so many modules are not supported yet. I have tried to provide an
upgrade path for the most popular modules which are not supported.
::checkbox
#default
Copy over modules
#tip
In Nuxt 3 all modules should belong under the `modules` key. You will need to copy over your modules from the `buildModules` and `modules` fields.
```ts [nuxt.config.ts]
export default defineNuxtConfig({
modules: [
// your modules
]
})
```
::
::checkbox
#default
Remove redundant modules
#tip
The following modules can be safely removed as functionality they provide is redundant.
- [@nuxtjs/typescript](https://typescript.nuxtjs.org/)
- [@nuxtjs/typescript-runtime](https://typescript.nuxtjs.org/)
- [@nuxtjs/composition-api](https://composition-api.nuxtjs.org/)
- [@nuxtjs/dotenv](https://github.com/nuxt-community/dotenv-module)
- [nuxt-build-optimisations / nuxt-webpack-optimisations](https://github.com/harlan-zw/nuxt-webpack-optimisations)
::
::checkbox
#default
Remove Modules No Longer Recommended
#tip
[@nuxtjs/axios](https://axios.nuxtjs.org/) and [@nuxt/http](https://http.nuxtjs.org/) are not recommended with Nuxt 3.
It's best practice to use the [$fetch / useFetch](https://nuxt.com/docs/migration/component-options/) API.
If are determined to use `$http` you can use [nuxt-alt/http](https://github.com/nuxt-alt/http).
See the Nuxt 3 and Axios discussion for additional context (removed, `nuxt/framework` discussions were retired after the Nuxt 3 merge into `nuxt/nuxt`).
::
::checkbox
#default
Use alternative modules
#tip
#### @nuxtjs/auth
Official `@nuxt/auth` support is a [work-in-progress](https://nuxt.com/docs/community/roadmap#core-modules).
In the meantime, you can use:
- [sidebase/nuxt-auth](https://github.com/sidebase/nuxt-auth)
- [nuxt-alt/auth](https://github.com/nuxt-alt/auth)
- [@nuxtjs/supabase](https://supabase.nuxtjs.org/)
#### @nuxtjs/pwa
The community [nuxt-pwa-module](https://github.com/kevinmarrec/nuxt-pwa-module) module seems to be the best option.
A core module for PWA seems to be undecided, if it goes ahead there's no ETA.
#### @nuxtjs/i18n
Official support is in beta, check the [docs](https://v8.i18n.nuxtjs.org/).
#### @nuxtjs/proxy
This feature is coming to nitro route rules. You can track it here: .
In the meantime, you can use the [nuxt-alt/proxy](https://github.com/nuxt-alt/proxy) module.
#### @nuxtjs/eslint-module
The Nuxt 2 version of this module is called `@nuxtjs/eslint-config`, you should remove this.
Instead, follow the [documentation](https://github.com/nuxt/eslint-config) for `@nuxt/eslint-config`.
#### @nuxtjs/sitemap
Not currently supporting Nuxt 3.
Instead, you can use:
- [nuxt-simple-sitemap](https://github.com/harlan-zw/nuxt-simple-sitemap) or [nuxt-seo-kit](https://github.com/harlan-zw/nuxt-seo-kit).
- [@funken-studio/sitemap-nuxt-3](https://github.com/funkenstudio/sitemap-module-nuxt-3)
#### @nuxtjs/robots
Not currently supporting Nuxt 3.
Instead, you can use:
- [nuxt-simple-robots](https://github.com/harlan-zw/nuxt-simple-robots) or [nuxt-seo-kit](https://github.com/harlan-zw/nuxt-seo-kit).
Alternatively, create your own robots.txt file at `public/robots.txt`.
```text [public/robots.txt]
User-agent: *
Disallow: /
```
OR you can generate robots dynamically.
```ts [server/routes/robots.txt.ts]
export default defineEventHandler(
e => `User-agent: *\nDisallow: /\nSitemap: https://YOUR_SITE/sitemap_index.xml`
)
```
#### @nuxtjs/vuetify
Official support is coming soon. You can use [nuxt-alt/vuetify](https://github.com/nuxt-alt/vuetify) in the meantime.
#### @nuxtjs/device
Not supporting Nuxt 3.
Create your own plugin to detect the device instead, you can use the [ua-parser-js](https://github.com/faisalman/ua-parser-js) package directly.
```ts [composables/useDevice.ts]
import UAParser from 'ua-parser-js'
export function useDevice() {
const parser = new UAParser()
const { os, browser, device } = parser.getResult()
return {
os,
browser,
device,
isMobile: device.type === 'mobile',
isDesktop: device.type === 'desktop',
isTablet: device.type === 'tablet',
// etc
}
}
```
```vue [pages/index.vue]
```
### @nuxtjs/moment
Not supporting Nuxt 3.
Create your own plugin to use Moment instead.
```ts [plugins/moment.ts]
import moment from 'moment'
import 'moment/locale/en'
export default defineNuxtPlugin((nuxtApp) => {
moment.locale('en')
moment.tz.setDefault('UTC')
return {
provide: {
moment,
}
}
})
```
```vue [pages/index.vue]
```
#### @nuxtjs/fontawesome
I'd recommend using [nuxt-icon](https://github.com/nuxt-modules/icon).
Alternatively,
see the Using FontAwesome in Nuxt 3 discussion for plugin code sample (removed, `nuxt/framework` discussions were retired after the Nuxt 3 merge into `nuxt/nuxt`).
#### @nuxtjs/dayjs
See [Nuxt3 support](https://github.com/nuxt-community/dayjs-module/issues/376) for plugin code sample.
#### @nuxtjs/toast
See the Examples of using the @nuxtjs/toast or any vue toast plugin? discussion for plugin code sample (removed, `nuxt/framework` discussions were retired after the Nuxt 3 merge into `nuxt/nuxt`).
#### @nuxtjs/gtm
You can create your own code to handle gtm.
```ts [nuxt.config.ts]
export default defineNuxtConfig({
app: {
head: {
script: [
{
innerHTML: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','YOUR_GTM_ID');`
},
],
},
}
})
```
#### bootstrap-vue
See [nuxt3 starter?](https://github.com/cdmoro/bootstrap-vue-3/issues/232) issue for code sample.
Work in progress.
#### @nuxtjs/google-analytics
See [Vue Plugins](https://nuxt.com/docs/guide/directory-structure/plugins/#vue-plugins) from the docs for an example of how to use Google Analytics with Nuxt 3.
Alternatively,
see [this comment](https://github.com/nuxt-community/gtm-module/issues/82#issuecomment-1373202592)
on how you can implement it with cookie control.
::
::checkbox
#default
Upgrade supported modules
#tip
If the module you're using supports Nuxt 3, it's important to check the migration notes.
It's likely that the upgrade will include some
breaking changes.
::
### 3. State management
If you're using Vuex 3, you will need to upgrade your state management, as Vue 3 does not support it.
::checkbox
#default
Consider if you need a state management library
#tip
Nuxt 3 ships with its own statement management with the [useState](https://nuxt.com/docs/getting-started/state-management) composable.
If you're using a state management library, you should consider if you need it.
Additionally, if you are dealing with data client side, you can always reference global refs. This isn't recommended for
server-side rendering as state is shared between users.
```ts [composables/useCounter.client.ts]
import { ref } from 'vue'
// You can import this counter from any file and the data will stay in sync
export const counter = ref(0)
export function useCounter() {
return {
counter,
increment() {
counter.value++
},
decrement() {
counter.value--
}
}
}
```
::
::checkbox
#default
Otherwise, migrate to Pinia or Vuex 4
#tip
[Pinia](https://pinia.vuejs.org/ssr/nuxt.html#nuxt-2-without-bridge) is the recommended state management solution for Nuxt 3, but you can use [Vuex 4](https://vuex.vuejs.org/guide/migrating-to-4-0-from-3-x.html) if you prefer.
::
### 4. Components
Components will only be parsed if they are imported. This means it's safe to copy over all our components and mixin files from Nuxt 2.
::checkbox
#default
Copy components
#tip
The folders have the same name and will be auto-imported. You don't need to add anything to `nuxt.config.ts`.
If your components aren't being found,
it may be because component auto-imports are done with path-prefixing by default,
see [Component Names](https://nuxt.com/docs/guide/directory-structure/components#component-names).
::
::checkbox
#default
Follow Component Options Migration
#tip
See the [Component Options Migration](https://nuxt.com/docs/migration/component-options) guide for more information.
::
### 5. Pages and dependencies
You will be copying over your pages one by one and their dependencies.
As you copy each page, you will run into errors. These errors may be related to breaking changes between Nuxt 2 and 3 or to missing dependencies.
For example, you'll have missing layout, plugin and potentially component dependencies.
It's important you reference the [Nuxt 3 Migration Guide](https://nuxt.com/docs/migration/overview) as you go.
Remember to ask for help in the community channels if you get stuck.
::checkbox
#default
Home Page
#tip
Start with the home page, `pages/index.vue`.
This will give you some momentum and help you get started.
::
::checkbox
#default
Copy static route pages
#tip
You'll start with routes which aren't dynamic which tend to be less complex and easier.
For example: `pages/about.vue`, `pages/contact.vue`, etc.
::
::checkbox
#default
Copy dynamic route pages
#tip
Nuxt 3 uses a new file system routing system, which means that all our pages need to be converted to the new format.
See [Dynamic Routes](https://nuxt.com/docs/migration/pages-and-layouts#dynamic-routes) for more information.
::
### 6. Server Routes / Middleware
Nuxt 3 uses a completely new server engine and HTTP framework.
This means that all our server routes and middleware need to be converted to the new format.
::checkbox
#default
Migrate server routes
#tip
See [Migrate Server Routes](https://nuxt.com/docs/migration/server).
You'll likely need to completely rewrite your server routes.
::
::checkbox
#default
Migrate server middleware
#tip
See [Migrate Server Routes](https://nuxt.com/docs/migration/server).
You'll likely need to completely rewrite your server middleware.
::
## Post Migration
### 1. Recommendations
::checkbox
#default
Migrate to Composition API
#tip
While this is not required, it is highly recommended.
The Composition API is a much better way to write Vue code and Nuxt provides
better support for it.
Tips:
- [vue-composition-convertor](https://github.com/miyaoka/vue-composition-converter) may give you a head start
- [ChatGPT](https://chat.openai.com/) is pretty good at converting Vue 2 options API to Vue 3 composition API
::
::checkbox
#default
Migrate everything to TypeScript
#tip
Nuxt 3 is TypeScript first, it is the best practice to have all of your code in TypeScript.
If you're using the Options API, you can simply add `lang="ts"` to your Vue SFCs. Otherwise, if you are using the options API, you
can make use of the `defineNuxtComponent` function.
```vue [components/MyComponent.vue]
```
::
::checkbox
#default
Load static assets from an absolute path
#tip
In Nuxt 2 it was common to load everything from `~/assets`, however with Nuxt 3 the default is to use absolute paths.
This way the bundler does not need to parse them. This will save you time when migrating and improve your build performance.
Simply move images which aren't going to change to `static` and load them from `/` instead of `~/assets`.
::
::checkbox
#default
Use Vue Macros
#tip
To make the migration from Vue 2 as simple as possible, you can make use of [vue-macros](https://vue-macros.sxzz.moe/guide/getting-started.html).
It provides a number of useful build-time macros, which speeds up the time to convert the code.
```vue [components/MyComponent.vue]
```
::
::checkbox
#default
Switch to UnJS / VueUse where appropriate
#tip
The [VueUse](https://vueuse.org/) and [UnJS packages](https://unjs.io/) all support Nuxt 2 and Nuxt 3. If you can migrate to using them over other dependencies,
you'll save yourself a lot of time.
::
### 2. Testing
Testing in Nuxt 3 is still under development. There is some documentation around it, but it is fairly minimal.
::checkbox
#default
Migrate to Vitest
#tip
Nuxt 3 uses Vitest for testing. If you're using Jest or Mocha, you'll need to migrate.
See the [Why Vitest](https://vitest.dev/guide/why.html) guide for more information.
::
::checkbox
#default
Use @nuxt/test-utils
#tip
Follow the documentation on the [Testing](https://nuxt.com/docs/getting-started/testing) page, to get started.
::
::checkbox
#default
Add vitest-environment-nuxt (optional)
#tip
The [vitest-environment-nuxt](https://github.com/danielroe/vitest-environment-nuxt) package will likely take over as the official testing environment for Nuxt 3.
It's still a work-in-progress with minimal documentation, but you may consider using it.
::
## Conclusion
You likely still have some migration pain ahead of you, but you're well on your way, congratulations.
Feel free to reach out to me directly on Twitter or Discord with any feedback or questions you may have. Keep in mind
that this migration guide does not intend to be exhaustive and is being actively updated.
This post took me many hours to put together. Please consider [sponsoring me](https://github.com/sponsors/harlan-zw) if this has helped you.
---
- **Page:** Scaling Your Vue Components for Mid-Large Size Apps
- **Source:** https://harlanzw.com/blog/scale-your-vue-components
- **Description:** Working on a mid-large size app usually means hundreds of components. How do you make sure these components will scale?
One of the key pieces in scaling your Vue app is having good component architecture.
- How are components named?
- What folder hierarchy should you use?
- How is component code scoped?
The cost of not having clear answers to these simple questions increases as your app grows.
My previous role was the tech lead at a startup. Growing pains were frequent with pivots, design changes and new features. All pushing our total component count up.
My below suggestions are what I came up with to solve *our* scaling issues. Your project will have its own requirements.
## 100+ component club
Let's assume once you hit 100+ components, then you are a mid-size app and you will be feeling your own growing pains.
Are you in the club? Run the following in your component folder:
```shell
# cd app/components
COMPONENTS=$(ls -lR **/*.vue | wc -l) && echo -e "You have ${COMPONENTS} components."
```
### Problems you may have
- Difficult to remember which component to use where
- Code is being repeated
- Monolithic components
- New components are being built instead of leveraging existing ones
- Inconsistent emits and props between components with the same functionality
- Technical debt is being ignored because it is too painful
## Solving component scaling with rules
Good code adheres to a set of rules. You either follow existing rules (syntax and conventions) or create
new ones and make sure others follow them (documentation and code reviews).
### Rule 0. Have good dev processes
There is no substitute for a good development process. You need to be following best continuous delivery, documentation and communication practices.
The rest of the rules will not help you if you are not functioning like a well-oiled machine.
### Rule 1. Know the style guide
You should be familiar with the official [Vue.js Style Guide](https://v3.vuejs.org/style-guide/).
It gives you clear, concise instructions on what you should and shouldn't do.
You should set up [eslint-plugin-vue](https://eslint.vuejs.org/) with the recommended rules.
```js [.eslintrc.js]
module.exports = {
extends: [
// ...
'plugin:vue/vue3-recommended',
// 'plugin:vue/recommended' // Use this if you are using Vue.js 2.x.
],
}
```
### Rule 2. Use a component naming convention
The bane of developers lives: how to name something.
You can address that by having an easy-to-follow convention on how to name a component. The convention also tells you where to put the component in your folder hierarchy.
{prefix}-{namespace}{?-class}
#### Prefix
> Base components (a.k.a. presentational, dumb, or pure components) that apply app-specific styling and conventions should all begin with a specific prefix, such as Base, App, or V.
A short prefix for *all* your components is preferable to the above.
Using a prefix avoids conflicts with HTML tags and third-party components. It also gives you scoped IDE autocompletion and more reusable components.
Prefixing becomes especially important when working with a component library ([Vuetify](https://vuetifyjs.com/), [VueStrap](https://yuche.github.io/vue-strap/), etc) or third-party components
([algolia](https://github.com/algolia/vue-instantsearch), [google maps](https://github.com/xkjyeah/vue-google-maps), etc).
You should use something which relates to your app, for example, I use `h` as the prefix because my site is [harlanzw.com](http://harlanzw.com).
```vue
Please enter your email to subscribe
Submit
```
You can use many prefixes for your components to help you with scoping code.
```vue
Sign In
```
#### Namespace
> Child components that are tightly coupled with their parent should include the parent component name as a prefix.
The style guide recommends starting the component name with the parent component. I've found using a *namespace* after the prefix instead is more flexible.
Namespaces avoid conflicts, improve IDE autocompletion and define the scope of the component.
You should map namespaces to a folder, this way you can group components, making them easier to find and use.
An example of a namespace is `Field`, for all our field components (text field, textarea, search, etc.).
```shell
components/
|- Field/ # namespace
|--- HFieldText.vue
|--- HFieldTextarea.vue
|--- HFieldSearch.vue
|--- HFieldAutocomplete.vue
|--- HFieldCheckbox.vue
```
You can then create conventions that components in a namespace should follow. For example these components should all have a `:value` prop and `$emit('input', value)`.
#### Class (optional)
> Component names should start with the highest-level (often most general) words and end with descriptive modifying words.
The final part of the convention is, in fact, the name of the component. Thinking of it as a class name makes the distinction between the namespace easier. You still want to follow the above style guide rule, our class names should be
general to descriptive.
The class should be optional. Namespaces can provide a default component to reduce the name of common components.
Imagine you have a project with a few buttons. Most of the time you want to use the default button, you shouldn't
need to name it `HButtonDefault.vue`.
```shell
components/
|- Button/ # namespace
|--- HButton.vue # The namespaces default component
|--- HButtonCallToAction.vue # A call to action button
|--- HButtonSubmitForm.vue # A button to submit forms
```
Recommendations on naming the class:
- Describe the application function of the component, rather than what it looks like.
- ❌ `HButtonRainbowFlashing.vue`
- ✅ `HButtonCallToAction.vue`
- Choose to be verbose if it adds clarity to the scope.
- ❌ `HProfileUser.vue`
- ✅ `HProfileAuthenticatedUsersCard.vue`
- Prefer full words over abbreviations. From the [style guide](https://v3.vuejs.org/style-guide/#full-word-component-names-strongly-recommended).
### Rule 3. Separate component scopes
Defining scopes for how components behave will guide you in staying DRY.
There are many ways to set this up. A good starting point is a scope for "shared" (a.k.a. base, presentational or dumb) components and "app" (a.k.a single-instance).
```shell
components/
|- app # Contains application logic
|- shared # Does not contain application logic
```
You could also pull out your "shared" components into their own npm package.
When creating new components it's natural to couple application logic in. With this setup, you'll think about component scopes more and how code can be re-used.
{width="750" height="1200" max-height="600"}
#### "Shared" Folder - Base Components
These components are re-usable and include form inputs, buttons, dialogues and modals. They should never contain application logic or state data.
You should be aiming to build your own "UI kit" from these components.
Copy-pasting your shared folder into a new project should work out of the box (assuming you handle dependencies).
#### "App" Folder - App components
App components do contain application logic and state data.
If you were to copy+paste an app component into a new project, it should not work.
## Example: Newsletter Sign Up
This exists as two "app" components, they contain logic for validation and posting to an API. They both contain "shared" components.
{width="841" height="296"}
```shell
components/
# application component scope
|- app/
|-- Newsletter # namespace
|--- HNewsletterForm.vue # validates and posts data
|--- HNewsletterCard.vue # handles successful form post
# shared component scope
|- shared/
|-- Alert/
|--- HAlertSuccess.vue
|-- Button/
|--- HButton.vue
|-- Card/
|--- HCard.vue
|-- Form
|--- HForm.vue
|-- Field/
|--- HFieldEmail.vue
```
```vue [HNewsletterForm.vue]
Subscribe
```
```vue [HNewsletterCard.vue]
Keep up to date
Thanks for signing up :)
```
## Example: Forum Thread
Now imagine you want to build a forum thread page. A user can see comments, upvote comments and post their own comment.
{width="950" height="847" max-height="600"}
Using `F` as our component prefix, let's look at what you need.
```shell
components/
# application component scope
|- app/
|-- Thread # namespace
|--- FThread.vue # Wraps the entire thread
|--- FThreadPost.vue # A single post / reply
|--- FThreadFormReply.vue # Form to submit a reply
|-- Field/
|--- FFieldComment.vue # Comment box for posts
|-- Button/
|--- FButtonUpvote.vue # The thumbs up button
# shared component scope
|- shared/
|-- Img/
|--- FImgAvatar.vue # Users photos
|-- Field/
|--- FFieldWYSIWYG.vue # Comment box for posts
|-- Card/
|--- FCard.vue # Gives posts a 'card' look
|-- Button/
|--- FButton.vue # Reply button for the post box
```
```vue [FThread.vue]
```
```vue [FThreadPost.vue]
{{ post.author.name }}{{ post.publishedAgo }}
```
```vue [FThreadFormReply.vue]
Please make sure you've read our Forum Rules before replying.
Reply
```
## Extra and optional rules
### Use An Automatic Component Importer
Being tied to import paths once you have a few hundred components is going to slow you down.
Using an [automatic component imports](/blog/vue-automatic-component-imports) will clean up your code. You'll be free to tinker with the directory structure of your components in any way you want.
### Typescript Components
The value of types, when you're working with objects is too good to pass up. Will save you hours down the line in developer experience. As a starting point, I'd try and get your shared components using Typescript.
```vue
```
### Components have "one job"
> Every component should have one job, any code in the component that isn't achieving that job shouldn't be there.
You should be thinking when you create a component what it's one core function is.
You can limit yourself with this mindset, but it's worth keeping in mind as you go.
### Create component demo pages
Using a package like [Storybook](https://storybook.js.org/) is a great idea, but it comes with overhead and when you're starting out it can be a bit overkill.
As a starting point, you can create pages under a `/demo` prefix and throw your components on it.
You want an easy way to find components and classes that are available.
Here is a rough demo page as an example: [Massive Monster](https://massivemonster.co) (the original demo page is gone). Keep it as basic as you want.
{width="939" height="464"}
### Mixins and composables
This one should be pretty obvious and there are enough articles elsewhere on using these.
You want to pull out common logic from components and put them in either mixins or composables.
Check out [VueUse](https://github.com/antfu/vueuse) for some ideas on what that could look like.
## Thanks for reading
If you like the technical side of Vue and Laravel, I'll be posting regular articles on this site. The best
way to keep up to date is by following me [@harlan_zw](https://twitter.com/harlan_zw) or signing up for the newsletter below.
---
- **Page:** How Does Vite Work - A Comparison to webpack
- **Source:** https://harlanzw.com/blog/how-the-heck-does-vite-work
- **Description:** A deep-dive into the comparisons between the earliest Vite version and webpack. Discover what I learnt digging into internals and how I, correctly, guessed Vite was the next big thing.
::tip
Note: This article was written for the alpha of Vite. I'd recommend reading Patak's great article on [The Vite Ecosystem](https://patak.dev/vite/ecosystem.html).
::
In rebuilding my old Nuxt.js personal site, I wanted to challenge myself to learn the latest tech, the unknown.
The unknown was the new project by Evan You: [⚡ Vite](https://github.com/vitejs/vite) (/veet/). Called Fast, for the 🇫🇷 Frenchies.
I'll be comparing how Vite works to the standard [webpack](https://github.com/webpack/webpack) config using [webpack-dev-server](https://github.com/webpack/webpack-dev-server), which all major Vue frameworks
are using.
We'll be looking at how Vite no-bundling works, by first looking at how webpack's bundling works and what the difference is. Afterwards I'll give you some
recommendations for setting up Vite for yourself.
Vite could the next best thing in tooling, currently, it's still in a pre-release stage though so be careful out there 🐛.
## A Recap on Vite
Vite is a web development build tool which supports Vue, React and Preact. It's an experimental new direction in how build tools can work with a greenfield ecosystem.
Vite's core functionality is similar to webpack + webpack-dev-server with some core improvements
on developer experience:
- ⌛ Less time waiting for your app to start, regardless of app size
- 🔥 Hot module reloading (HMR) that is basically instant, regardless of app size
- 🔨 On-demand compilation
- 🙅♂️ Zero configuration for numerous pre-processors out of the box
- 📜 Esbuild powered typescript / jsx (super quick)
### Speed Example
To give you a quick idea on how much faster it is, the below comparison is for Vue CLI which uses webpack. The bigger your app
is the more noticeable the speed difference will be.
::figure
| | Build Time | Dev Server Start Time | Dev Page Load Time |
| ------- | ----------------------------------------: | -----------------------------------------: | -----------------------------------------: |
| Vue CLI | 5.14s | 2568ms | 320ms |
| Vite | 2.39s | 232ms️ | 379ms |
New Vue 3 project / 10 components / no Babel / 2nd run, in development.
::
## Vite vs webpack
The main functional difference you'll notice with Vite and your webpack app, is how code is served in development and which modules are supported.
Don't worry if the below terms don't make sense to you, we'll be exploring them below.
### webpack (Nuxt.js / Vue CLI / etc)
- Supported Modules: [ES Modules](https://web.archive.org/web/20260511134339/https://2ality.com/2014/09/es6-modules-final.html), [CommonJS](http://wiki.commonjs.org/) and [AMD Modules](https://github.com/amdjs/amdjs-api/wiki/AMD)
- Dev Server: Bundled modules served via webpack-dev-server using [Express.js](https://expressjs.com/) web server
- Production Build: webpack
### Vite
- Supported Modules: [ES Modules](https://web.archive.org/web/20260511134339/https://2ality.com/2014/09/es6-modules-final.html)
- Dev Server: Native-ES-Modules, served via Vite using a [Koa](https://github.com/koajs/koa) web server
- Production build: [Rollup](https://github.com/rollup/rollup)
::tip
Check out Mozilla's article on ES Modules if they're new to you.
::
## Understanding webpack
To understand how Vite works, it's best to look at how webpack works first. Even with its popularly, understanding webpack can be intimidating, so I'll try to keep it simple.
webpack is versatile in what you can do with it, but at its core, it will:
- Starting with an entry file, build a tree of your dependencies: all the imports, exports, requires from your code/files
- Transform / compile modules: think transpiling js for older browsers, turning SCSS into CSS
- Use algorithms to sort, rewrite and concatenate code
- Optimise
### webpack In Development
Assuming you're using one of the main Vue frameworks, when you start your app in development, it is going to do a few things:
1. Bundle all of your code
2. Start the webpack-dev-server, the Express.js web server which will serve the bundled code
3. Setup sockets which will handle the Hot Module Reloading
As you may notice with your own apps, the bigger they grow, the longer you have to wait to start coding.
{width="789" height="549"}
Bundling in development is quicker because you don't need to do as much with the code, however,
as your app grows, it will become painfully slow, especially on older machines.
### webpack Component Example
I created a default Vue 3 [Vue CLI](https://cli.vuejs.org/) project, which has an entry `App.vue` file using the `HelloWorld.vue` component.
Let's see how this component gets to my browser.
HelloWorld.vue component:
```vue [HelloWorld.vue]
{{ msg }}
```
When I start my app and visit localhost I get the following HTML from the Express.js server.
```html
```
You'll notice we have 2 script files there: `chunk-vendor.js` and `app.js`. On inspecting them you'd see a lot of gibberish looking code.
it helps to use the [webpack-bundle-analyzer](https://www.npmjs.com/package/webpack-bundle-analyzer) to see how it works visually.
#### chunk-vendors.js
These are third-party modules, usually coming from `node_modules`. The two main libraries in here are Vue itself and sockjs which is used for HMR.
{width="1860" height="960" max-height="400"}
#### app.js
This is all the code for my application. It contains components, assets, etc. You'll notice that for an SFC it splits
it into multiple modules.
{width="1857" height="953" max-height="450"}
Taking a quick look at the `app.js` file, we can find some of the `HelloWorld` component code. As you can see in the above image,
all parts of the SFC are separate modules: the wrapper, CSS, template, js.
The wrapper module is defining and importing the other models, some beautiful code.
```js
/***/ "./src/components/HelloWorld.vue":
/*!***************************************!*\
!*** ./src/components/HelloWorld.vue ***!
\***************************************/
/*! exports provided: default */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _HelloWorld_vue_vue_type_template_id_469af010_scoped_true__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./HelloWorld.vue?vue&type=template&id=469af010&scoped=true */ \"./src/components/HelloWorld.vue?vue&type=template&id=469af010&scoped=true\");\n/* harmony import */ var _HelloWorld_vue_vue_type_script_lang_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./HelloWorld.vue?vue&type=script&lang=js */ \"./src/components/HelloWorld.vue?vue&type=script&lang=js\");\n/* empty/unused harmony star reexport *//* harmony import */ var _HelloWorld_vue_vue_type_style_index_0_id_469af010_scoped_true_lang_css__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./HelloWorld.vue?vue&type=style&index=0&id=469af010&scoped=true&lang=css */ \"./src/components/HelloWorld.vue?vue&type=style&index=0&id=469af010&scoped=true&lang=css\");\n\n\n\n\n\n_HelloWorld_vue_vue_type_script_lang_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"].render = _HelloWorld_vue_vue_type_template_id_469af010_scoped_true__WEBPACK_IMPORTED_MODULE_0__[\"render\"]\n_HelloWorld_vue_vue_type_script_lang_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"].__scopeId = \"data-v-469af010\"\n/* hot reload */\nif (true) {\n _HelloWorld_vue_vue_type_script_lang_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"].__hmrId = \"469af010\"\n const api = __VUE_HMR_RUNTIME__\n module.hot.accept()\n if (!api.createRecord('469af010', _HelloWorld_vue_vue_type_script_lang_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"])) {\n api.reload('469af010', _HelloWorld_vue_vue_type_script_lang_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"])\n }\n \n module.hot.accept(/*! ./HelloWorld.vue?vue&type=template&id=469af010&scoped=true */ \"./src/components/HelloWorld.vue?vue&type=template&id=469af010&scoped=true\", function(__WEBPACK_OUTDATED_DEPENDENCIES__) { /* harmony import */ _HelloWorld_vue_vue_type_template_id_469af010_scoped_true__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./HelloWorld.vue?vue&type=template&id=469af010&scoped=true */ \"./src/components/HelloWorld.vue?vue&type=template&id=469af010&scoped=true\");\n(() => {\n api.rerender('469af010', _HelloWorld_vue_vue_type_template_id_469af010_scoped_true__WEBPACK_IMPORTED_MODULE_0__[\"render\"])\n })(__WEBPACK_OUTDATED_DEPENDENCIES__); }.bind(this))\n\n}\n\n_HelloWorld_vue_vue_type_script_lang_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"].__file = \"src/components/HelloWorld.vue\"\n\n/* harmony default export */ __webpack_exports__[\"default\"] = (_HelloWorld_vue_vue_type_script_lang_js__WEBPACK_IMPORTED_MODULE_1__[\"default\"]);");
```
The main takeaway here is that within the `app.js` file contains all modules for my app.
webpack does let you chunk the bundles how you like, for Nuxt.js it chunks routes individually. The more chunks though,
the more requests and more potential blocking js.
You may see the problem here, we have multiple monolith files that need to be generated anytime we want to use our app.
When we change a file for HMR, we need to regenerate the entire file.
## Understanding Vite
Vite doesn't set out to be a new bundler. Rather, it's a pre-configured build environment using the Rollup
bundler and a tool for local development.
### Vite In Development
Vite makes the assumption that developers are going to be using the latest browser versions, so it can safely rely on the
latest JS functionality straight from the browser - in other words, no babel transpiling!
When you start Vite for the first time pre-optimisations will be done on your `node_modules`, then [Koa](https://github.com/koajs/koa),
a light-weight node web server starts to serve your app.
There is no bundling or compiling needed to start the dev server, so it's damn quick (< 300ms).
When you open your Vite app you'll be served the `index.html` from the server. The browser is going to read the `index.html`
and know how to parse the Native-ES-Module code.
```html
```
Parsing the Native-ES-Module means it will read the `export` and `import` lines from your code. It will convert those
lines into HTTP requests back to the server, where it will again read the `export` and `import` lines and make new requests.
It will keep going through like this with your dependencies recursively, in a waterfall process, until everything has been resolved.
{width="964" height="551" max-height="450"}
### Vite Component Example
Let's take a look at how these requests are working in the browser. After I open my app at `http://localhost:3000`, the browser has fetched the following `index.js` file from the web server:
```js {5}
import '/@theme/styles/main.scss?import';
import Layout from '/@theme/Layout.vue';
import NotFound from '/@theme/NotFound.vue';
import CardPost from '/@theme/components/CardPost.vue';
const theme = {
Layout,
NotFound,
enhanceApp({ app, }) {
app.component('CardPost', CardPost)
}
};
export default theme;
```
::tip
Normally, in webpack, you would have to transpile this code to something legacy browsers can understand. Newer browsers know what to do with it, see [es6 module dynamic import](https://caniuse.com/es6-module-dynamic-import).
::
Let's drill into that highlighted line which is requesting the CardPost SFC. The browser will turn that import into a request for `http://localhost:3000/@theme/components/CardPost.vue`.
```vue [CardPost.vue]
...
```
Once the web server gets this request, it will need to compile the `CardPost.vue` file to javascript and send it back. Vite has many
optimisations around the Vue compiling so this takes no time.
Let's see what comes through:
```js [CardPost.vue - Transpiled]
import '/@theme/components/CardPost.vue?type=style&index=0'
import posts from '/.vitepress/posts.ts'
import { render as __render } from '/@theme/components/CardPost.vue?type=template'
const __script = {
props: {
postIndex: {
type: Number,
required: true,
}
},
computed: {
post() {
return posts[this.postIndex]
}
}
}
__script.__scopeId = 'data-v-287b4794'
__script.render = __render
__script.__hmrId = '/@theme/components/CardPost.vue'
typeof __VUE_HMR_RUNTIME__ !== 'undefined' && __VUE_HMR_RUNTIME__.createRecord(__script.__hmrId, __script)
__script.__file = '/home/harlan/sites/new.harlanzw.com/app/.vitepress/theme/components/CardPost.vue'
export default __script
```
Cool, so quite a bit going on here. The main thing to note here is how it's split up the SFC into different modules which
will need separate requests to fetch. It hasn't bundled these imports into the SFC or some other monolith file.
- Dependencies: `/.vitepress/posts.ts`
- Template: `/@theme/components/CardPost.vue?type=template`
- Stylesheet: `/@theme/components/CardPost.vue?type=style&index=0`
If you're curious, this is what the style component response looks like, some nifty for sure.
```js [CardPost.vue - CSS]
import { updateStyle } from '/vite/client'
const css = '.card-post[data-v-287b4794] {\n position: relative;\n}\n.card-post .prose[data-v-287b4794] {\n max-width: 100% !important;\n}\n.card-post__link[data-v-287b4794] {\n position: absolute;\n left: 0;\n top: 0;\n width: 100%;\n height: 100%;\n content: " ";\n z-index: 1;\n}\n.card-post__content[data-v-287b4794] {\n background-color: white;\n z-index: 1;\n}\n.card-post__effect[data-v-287b4794] {\n z-index: -1;\n content: " ";\n height: 30px;\n width: 100%;\n position: absolute;\n background-color: #059669;\n transition: 0.2s;\n opacity: 0;\n top: 30px;\n}\n.card-post:hover .card-post__effect[data-v-287b4794] {\n top: -5px;\n opacity: 1;\n transform: rotate(0.25deg);\n}'
updateStyle('287b4794-0', css)
export default css
```
You can see how the above allows the Hot Module Replacement to work efficiently. When you have a module that is changed,
say the styles within a component, instead of reloading the entire component tree, only the style module needs to be replaced.
You can also imagine with the above, where Vite slows down. Imagine hundreds of HTTP requests which rely on nested HTTP requests, recursively.
Fortunately, there are optimisation to avoid this situation after the first load. The server will return a 304
Unmodified HTTP Status code for modules which haven't changed, meaning they will use the browser's cached version of the file.
Vite scales well for any app size because it only needs to request the modules for the route you're on.
## Production Builds
Since Vite is using Rollup, pre-configured, you'd expect a similar output from Vite as webpack. Vite does boast a quicker
builder and potentially a smaller artifact size, as Rollup is a more efficient bundler than webpack.
The main gotcha is that Vite can still only support ES Modules in the production build, meaning you can't have any dependencies
which don't have ES Module exports.
Vite is also pre-configured to handle your build as a universal app. A universal app is built using a client (virtual browser)
and a server (node). Allowing it to pre-render the HTML pages, so robot crawlers can fetch your page content without executing
js and speeding up the initial load for users. That means SEO friendly static sites out of the box 🎉.
## Summary
While I haven't touched on a lot of the complexities of Vite and webpack, I've tried to show you the main difference, how
bundling and no-bundling look in action.
Hopefully you've seen why Vite is promising alternative. There is so much potential in the ecosystem at the moment, watch this space, given 12-months we could see an explosion of Vite related projects.
If you want to find out more about Vite, I'd watch Evan's talk on [Vite & VitePress](https://www.youtube.com/watch?v=xXrhg26VCSc&).
## Getting started with Vite
I'd recommend just spinning up bare-bones Vite to get a feel for it. It's really easy, takes less than a minute.
```shell [bash]
npm init vite-app
```
Once you are sold, it's worth checking out [the ecosystem](https://github.com/vitejs/awesome-vite) before you build.
### Recommendations
::tip
You shouldn't be looking to replace Vue CLI or webpack with Vite for existing projects yet, but it may be valuable to check out for new smaller scoped projects.
::
The Vite ecosystem isn't that mature yet, the two main projects I'd recommend checking out are [VitePress](https://vitepress.vuejs.org/) and [Vitesse](https://github.com/antfu/vitesse).
If you are in need of a documentation site then VitePress is awesome, you can follow the VuePress documentation to fill in any gaps. VitePress abstracts away
the Vite configuration, which will be limiting for non-documentation sites.
Otherwise, I'd choose Vitesse as it's going to give you more flexible on customising your app. Vitesse offers a pre-configured `vite.config.js`, so you can easily
strip anything out you don't need to add whatever you'd like to it.
If you like my blog (VitePress + TailwindCSS), then you're more than welcome to [clone it](https://github.com/harlan-zw/harlanzw.com).
## Thanks for reading
If you like the technical side of Vue and Laravel, I'll be posting regular articles on this site. The best
way to keep up to date is by following me [@harlan_zw](https://twitter.com/harlan_zw).
---
- **Page:** Building a Vue Auto Component Importer - A Better Dev Experience
- **Source:** https://harlanzw.com/blog/vue-automatic-component-imports
- **Description:** Components magically being imported into your app is the latest developer experience trend in Vue. Why does it exist and how does it work?
When first learning Vue, you are taught you need to import and add components to `components` in the script block.
```vue
```
However, there's been a recent trend to "upgrade" the Vue developer experience (DX), having components magically import themselves
at compile-time.
```vue
```
In the wild, you can find auto component imports in most popular Vue frameworks, as part of the core or a plugin.
- [Nuxt Components](https://github.com/nuxt/components)
- [Vuetify](https://github.com/vuetifyjs/vuetify-loader)
- [Chakra](https://github.com/segunadebayo/chakra-ui)
- [Vue CLI](https://github.com/harlan-zw/vue-cli-plugin-import-components) (built by me)
- [Vite](https://github.com/antfu/vite-plugin-components)
This article will look at: why automatic component imports exist, how you can easily build our own auto component importer using
a Webpack loader and what the performance cost of using them has on your app.
Finally, we'll look at some other compile-time DX upgrades that are possible.
## Why Automatic Component Imports?
The *why* that comes first to my mind, is the developer experience is great. No more confusion or typos on import paths,
refactoring becomes easier and there's less code overall.
The unintuitive but equally great advantage is found in the problem that this feature first solved.
The UI framework [Vuetify](https://vuetifyjs.com/) is a huge library of over 80 components, coming in at [99.4KB](https://cdnjs.cloudflare.com/ajax/libs/vuetify/2.3.17/vuetify.js)
for their scripts. As far as I know, they were the first to introduce automatic component imports.
### Problem: UI Framework Bloat
One of the complaints you'll hear about using a UI framework over something simple like [TailwindCSS](https://tailwindcss.com/),
is the bloat it will add to your app.
This is a valid concern. It's unlikely your application is going to need half the components that a UI framework has to offer. Forcing
browsers to download code that will never run, dead code, is not ideal.
Additionally, this component bloat can make import paths harder to work with and further scope for issues to pop up.
So, how do Vuetify and other UI frameworks overcome their inherent bloat?
### Solution: webpack Optimisations
As is the way, webpack is here to magically solve our problems with [tree shaking](https://webpack.js.org/guides/tree-shaking/) and [code splitting](https://webpack.js.org/guides/code-splitting/) optimisations.
If tree shaking is new to you, you can think of it as an optimisation to remove code that isn't explicitly used. Banishing
'dead' code to the shadow realm.
The tree shaking optimisation requires ES2015 module syntax, (i.e. `import` and `export`) and a production build. The code can't be compiled
to CommonJS modules (i.e. `require`) for it to work.
So how does all this relate to automatic component imports?
With Vuetify handling the imports of your components (*[à la carte](https://vuetifyjs.com/en/features/treeshaking/)* as they call it), they
can ensure webpack optimisations are running out of the box for your app with their component library.
> The A la carte system enables you to pick and choose which components to import, drastically lowering your build size.
> This will also make code-splitting more effective, as webpack will only load the components required for that chunk to be displayed.
## Fundamental: How Does webpack Load Vue Files?
Before we jump into building our own automatic component importer, we'll need to have a basic understanding of how webpack loads Vue files.
When you request a resource (such as a file) in webpack, it pushes the request through a pipeline of webpack loaders to resolve the output. A webpack loader is a piece of code which will transform a resource from one thing into another, it has an `input` and `output`.
For example, the [raw-loader](https://v4.webpack.js.org/loaders/raw-loader/) will read a file and give you the string contents.
The `input` is a path to a file in your filesystem, the `output` is the string contents of the file.
```js
import txt from 'raw-loader!./hello.txt'
// txt=HelloWorld
```
The `vue-loader` is the loader for `.vue` files. The loader compiles and bundles your component Single File Component (SFC) into code
that the browser can understand and run.
### Vue Loader in Action
Let's take a look at an example of input and output from the vue-loader.
#### Input: App.vue
This is the default entry file for Vue CLI with Vue 3.
```vue
```
#### Output: App.vue
Internally, the loader parses this code using the compiler, getting an SFC descriptor object that is used to create the
final string output of the loader.
```js
import script from './App.vue?vue&type=script&lang=js'
import { render } from './App.vue?vue&type=template&id=7ba5bd90'
import './App.vue?vue&type=style&index=0&id=7ba5bd90&lang=css'
export * from './App.vue?vue&type=script&lang=js'
script.render = render
script.__file = 'src/App.vue'
export default script
```
Note: I've removed the Hot Module Reloading (HMR) code for simplicity here.
The output of the loader isn't that important to understand, just know that the vue-loader has an in and out function. The output
is usually parsed to another loader such as [babel-loader](https://github.com/babel/babel-loader) before being chunked.
## Building an Automatic Component Importer
If you have some spare time, I'd encourage you to join along. You can use [Vue CLI](https://cli.vuejs.org/) with the Vue 3 preset.
```shell
vue create auto-component-importer -p __default_vue_3__
```
To begin, let's remove the manual import from the entry SFC, like so:
### New App.vue
```vue [App.vue]
```
When we load our `App.vue`, the `HelloWorld` doesn't work, as expected. Our goal is to get it to work without touching the Vue code.
### Step 1. Modify the webpack Configuration
We need to make sure the loader we'll be making is going to run after the vue-loader.
```js [./vue.config.js]
module.exports = {
chainWebpack: (config) => {
config.module
.rules
.get('vue')
.use('components')
.loader(require.resolve('./imports-loader'))
.before('vue-loader')
.end()
}
}
```
If you'd like to see the raw webpack config example, open the below.
webpack.config.js example
```js [webpack.config.js]
module.exports = {
// ...
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader'
}
]
}
}
```
Knowing that webpack loaders are loaded from bottom to top, we would modify the configuration as so:
```js [webpack.config.js]
module.exports = {
// ...
module: {
rules: [
{
test: /\.vue$/,
use: [
{
loader: require.resolve('./imports-loader'),
},
{
loader: 'vue-loader',
}
]
},
]
}
}
```
::tip
Normally a webpack would handle this configuration changing for you.
::
Now we create the loader called `imports-loader.js` in your apps root directory. We're going to make sure we only run it for the virtual SFC module.
```js [imports-loader.js]
module.exports = function loader(source) {
// only run for the virtual SFC
if (this.resourceQuery)
return source
console.log(source)
return source
}
```
The `source` variable is the output of the vue-loader, the [Output: App.vue](#output-app-vue).
Here we can now change anything about how our components work by modifying the vue-loader output.
### Step 2. Dumb Compile-Time Import
As a proof of concept, let's try to import the `HelloWorld.vue` component so our [New App.vue](#new-app-vue) works.
At this stage, we can just append the import code on to the `source`.
```js [imports-loader.js]
module.exports = function loader(source) {
// only run for the virtual SFC
if (this.resourceQuery)
return source
return `${source}
import HelloWorld from "@/components/HelloWorld.vue"
script.components = Object.assign({ HelloWorld }, script.components)
`
}
```
Your `App.vue` now knows what the HelloWorld component is and works. Try it yourself.
Note: This is a *dumb* solution, as it will be modifying `HelloWorld.vue` to also import itself.
### Step 3. Making it smart
A smarter solution would give us the ability to add components to our component folder and use them straight away without
any imports.
#### a. Scan components
The first step in making it smarter is we need to create a map of the components files we want to automatically import.
We recursively iterate over the components folder and do some mapping.
```js [a. Scan components]
const base = './src/components/'
const fileComponents = (await globby('*.vue', { cwd: base })).map((c) => {
const name = path.parse(c).name
const shortPath = path.resolve(base).replace(path.resolve('./src'), '@')
return {
name,
import: `import ${name} from "${shortPath}/${c}"`
}
})
// [ { name: 'HelloWorld', import: 'import HelloWorld from "@/components/HelloWorld.vue"' } ]
```
#### b. Find the template tags
To understand what components are being used, we need to have our new loader to compile the SFC `` blocks.
```js [b. Find the template tags]
const compiler = require('@vue/compiler-sfc')
const parsed = compiler.parse(fs.readFileSync(`${this.context}/${path.basename(this.resourcePath)}`, 'utf8')).descriptor
const template = compiler.compileTemplate({
id: 'tmp',
source: parsed.template.content,
filename: this.resourcePath,
})
const componentTags = template.ast.components
// [ 'HelloWorld' ]
```
Note: For simplicity, we're using Vue 3's compiler. The above won't work for Vue 2.
#### c. Matchmaking
With our freshly compiled template, we need to match the components we found in our template with the mapped component files
from [a. Scan Components](#a-scan-components).
```js [c. Match making]
const matches = []
componentTags.forEach(tag => matches.push(first(filter(fileComponents, c => c.name === tag))))
// [ { name: 'HelloWorld', import: 'import HelloWorld from "@/components/HelloWorld.vue"' } ]
```
If you wanted to match non-PascalCase names, you would modify this matcher function.
#### d. Insert the new dynamic imports
The final piece of the puzzle is appending the list of matched components and inserting the import line and assigning
the components.
```js [d. Insert the new dynamic imports]
if (!matches.length)
return source
const newContent = `
${matches.map(c => c.import).join('\n')}
script.components = Object.assign({ ${matches.map(c => c.name).join(', ')} }, script.components);
`
const hotReload = source.indexOf('/* hot reload */')
if (hotReload > -1)
source = `${source.slice(0, hotReload) + newContent}\n\n${source.slice(hotReload)}`
else
source += `\n\n${newContent}`
return source
```
We need to insert the new content before the HMR code if available, otherwise we need to restart our app to find
new components.
### Putting it all together
Below is the full `imports-loader.js` for reference. This loader should *just work*. Create
a new component and then use it straight away, make sure you use PascalCase.
```js [imports-loader.js]
const fs = require('node:fs')
const path = require('node:path')
const globby = require('globby')
const filter = require('lodash/filter')
const first = require('lodash/first')
module.exports = async function loader(source) {
// only run for the non-query requests
if (this.resourceQuery)
return source
// a. Scan components
const base = './src/components/'
const fileComponents = (await globby('*.vue', { cwd: base })).map((c) => {
const name = path.parse(c).name
const shortPath = path.resolve(base).replace(path.resolve('./src'), '@')
return {
name,
import: `import ${name} from "${shortPath}/${c}"`
}
})
// b. Find the template tags
const compiler = require('@vue/compiler-sfc')
const parsed = compiler.parse(fs.readFileSync(`${this.context}/${path.basename(this.resourcePath)}`, 'utf8')).descriptor
const template = compiler.compileTemplate({
id: 'na',
source: parsed.template.content,
filename: this.resourcePath,
})
const componentTags = template.ast.components
// c. Match making
const matches = []
componentTags.forEach(tag => matches.push(first(filter(fileComponents, c => c.name === tag))))
// d. Insert the new dynamic imports
if (!matches.length)
return source
const newContent = `
${matches.map(c => c.import).join('\n')}
script.components = Object.assign({ ${matches.map(c => c.name).join(', ')} }, script.components);
`
const hotReload = source.indexOf('/* hot reload */')
if (hotReload > -1)
source = `${source.slice(0, hotReload) + newContent}\n\n${source.slice(hotReload)}`
else
source += `\n\n${newContent}`
return source
}
```
::tip
There are several issues and edge cases with the above code, this is merely a proof of concept and shouldn't be used in
production.
If you're after a more complete solution you should clone the repos listed before.
::
## Problems With Automatic Component Imports
Hopefully, you now have a better understanding of how auto component importing works. While working through that rough proof of
concept, you may have foreseen some issues.
### Performance Cost
For automatic component imports to provide their magic, they need to parse the SFC and compile the template at compile time. If you recall,
we are running our loader after the `vue-loader`, that means this compilation has already been completed and is happening again.
This means that by using this feature, we are potentially **doubling our component build time**. Which affects the hot module
replacement speed, the web-dev-server boot-time and the production build time.
Saying that certain optimisations can and are made. Loader output can be cached with one line, so unless we change a file
we don't need to recompile it.
```js [imports-loaders.js]
// ...
module.exports = async function loader(source) {
this.cache()
// ...
}
```
For Vue 3 there may be a new way to optimise this feature. I've based the proof of concept on how the existing Vue 2 plugins work.
### Static code only
If you have a dynamic import then it's not going to work. I don't think this is a massive issue as you can
work around it with manual imports or using the `v-if` on inline components. Consider the below
code:
```vue
```
For now, the automatic import of `ComponentA` and `ComponentB` is not possible.
### Stricter component naming
Due to the nature of mapping a file name to a component name, it sets a few requirements around how you name your components.
If you're following the Vue [style-guide](https://vuejs.org/v2/style-guide/#Multi-word-component-names-essential) for component naming, you shouldn't have an issue.
If you're going to adopt automatic component imports I'd recommend the following rules:
- Namespace all components (i.e `HButtonOutline`, `H` is the namespace)
- Avoid non-unique component file names
- Use nested folders to separate scopes
## Other Compile-Time "Upgrades"
### Import Directive Support
When compiling the template, we can also see when specific directives are used.
If the directive is not globally registered, then we can do an automatic compile-time import of it.
```vue
```
We could imagine that we could write some code which would inject the directive such as `import MyDirective from "@/directives/MyDirective"`.
### Progressive Images
We hook into the compiling again and replace the source of our images with compile-time low-resolution versions.
**Input**
```vue
```
**Output**
```vue
```
### Async imports
The `@nuxt/components` package does offer this functionality as opt-in through a `Lazy` prefix on the component.
It makes use webpack's [Lazy Loading](https://webpack.js.org/guides/lazy-loading/) which bundles imports as their own dependency.
This is useful for if you have heavy components that aren't above the fold.
## Conclusion
Vue is already one of the most developer-friendly frontend frameworks around, with continued improvements in the dev experience
Vue will continue to flourish.
While these compile-time upgrades are not needed, they do make life easier. The possibilities
with injecting code at compile time opens up many opportunities for reducing the 'chores' that seem to follow us around
project to project.
## Thanks for reading
webpack and Vue internals are a challenging topic and if you made it all the way through, pat yourself on the back.
If you like the technical side of Vue and Laravel, I'll be posting regular articles on this site. The best
way to keep up to date is by following me [@harlan_zw](https://twitter.com/harlan_zw) or signing up for the newsletter below.